Prof. Marc Los Huertos • Pomona College • EA030 Environmental Analysis
$ operator.
lm().
What this module covers. R is an open-source statistical computing environment used across environmental science, ecology, hydrology, and climate research. This module builds the foundation you need to work with data in R: understanding how R stores information as objects, how to combine objects into data frames, and how to explore and model environmental data. All data used here are self-contained — no files to download.
Before writing complex analyses, get comfortable using R
interactively. R understands standard arithmetic directly at the console
prompt (>).
| Operation | Operator | Example | Result |
|---|---|---|---|
| Addition | + |
6 + 5 |
11 |
| Subtraction | - |
6 - 5 |
1 |
| Multiplication | * |
6 * 5 |
30 |
| Division | / |
3 / 5 |
0.6 |
| Exponentiation | ^ |
3 ^ 2 |
9 |
| Integer division | %/% |
7 %/% 2 |
3 |
| Modulo (remainder) | %% |
7 %% 2 |
1 |
# Basic arithmetic — type these in your console and observe the results
6 + 5
## [1] 11
3 / 5
## [1] 0.6
3 ^ 2
## [1] 9
For more complex calculations, R provides built-in functions. All functions use parentheses; what goes inside is called the argument.
log10(100) # base-10 logarithm: how many pH units?
## [1] 2
log(2.718282) # natural log (base e)
## [1] 1
sqrt(144) # square root
## [1] 12
abs(-7.3) # absolute value — useful for anomalies
## [1] 7.3
round(3.14159, 2) # round to 2 decimal places
## [1] 3.14
💡 No news is good news When R executes your command
without printing an error, it worked. Silence means success. When
something does go wrong, R prints a message starting with
Error or Warning. Read those messages
carefully — they usually tell you exactly what went wrong.
Many ecological processes follow the Michaelis-Menten form — nutrient uptake by phytoplankton, enzyme kinetics, oxygen consumption. The equation is:
\[V = V_{max} \frac{S}{K_s + S}\]
where \(V\) is the uptake rate, \(V_{max}\) is the maximum rate, \(S\) is substrate concentration, and \(K_s\) is the half-saturation constant. With \(V_{max} = 0.18\), \(S = 0.5\), and \(K_s = 0.3\):
Vmax <- 0.18
S <- 0.5
Ks <- 0.3
V <- Vmax * (S / (Ks + S))
V # print the result
## [1] 0.1125
Notice how assigning values to named objects (<-)
makes the formula readable and easy to modify. Change \(S\) from 0.5 to 1.5 and re-run — does the
uptake rate saturate as expected?
📌 Try it: explore operator precedence Type 2
+ 3 * 4 and then (2 + 3) * 4. R follows standard
mathematical order of operations. When in doubt, use parentheses to make
your intent explicit.
The single most important concept in R is the
object. Everything in R — a number, a list of
measurements, a statistical model, a plot — is an object with a name you
assign. You create objects with the assignment operator
<-.
# Create a scalar object (length-1 vector)
air_temp <- 18.5 # degrees Celsius, a single measurement
# Print it by typing its name
air_temp
## [1] 18.5
Object names are case-sensitive: air_temp,
Air_Temp, and AIR_TEMP are three different
objects. R convention uses snake_case (lowercase words
separated by underscores). Start names with a letter, never a digit.
Don’t try to memorize all of these now. As you work through this module you will create each type naturally.
A vector stores an ordered sequence of values of a
single type (numeric, character, or logical). Use c() —
short for concatenate — to create one.
# A vector of water temperatures measured at a field site (°C)
temperature <- c(22.1, 21.8, 20.5, 18.2, 14.3, 11.0, 10.2, 9.8)
# A corresponding vector of depths (m)
depth <- c(0, 1, 2, 3, 4, 5, 6, 7)
# Print them
temperature
## [1] 22.1 21.8 20.5 18.2 14.3 11.0 10.2 9.8
depth
## [1] 0 1 2 3 4 5 6 7
Once you have a vector, a set of built-in functions let you explore it:
length(temperature) # how many observations?
## [1] 8
sum(temperature) # total sum
## [1] 127.9
mean(temperature) # arithmetic mean
## [1] 15.9875
min(temperature) # minimum value
## [1] 9.8
max(temperature) # maximum value
## [1] 22.1
range(temperature) # min and max together
## [1] 9.8 22.1
sd(temperature) # standard deviation
## [1] 5.290271
Typing out every value by hand gets tedious. Use seq()
and rep() for structured sequences:
# Depths from 0 to 14 m in 2-m increments
depth_2m <- seq(from = 0, to = 14, by = 2)
depth_2m
## [1] 0 2 4 6 8 10 12 14
# Replicate a treatment label 5 times each
treatment <- c(rep("Control", 5), rep("Heated", 5), rep("Ambient", 5))
treatment
## [1] "Control" "Control" "Control" "Control" "Control" "Heated" "Heated"
## [8] "Heated" "Heated" "Heated" "Ambient" "Ambient" "Ambient" "Ambient"
## [15] "Ambient"
Vectors don’t have to contain numbers. Environmental data often include site names, species identifiers, or quality-control flags:
# Character vector of sampling sites
sites <- c("Kern River", "Mojave Basin", "Santa Ana", "LA River")
# Logical vector: is the site impaired?
impaired <- c(FALSE, TRUE, TRUE, TRUE)
# Check type
class(sites)
## [1] "character"
class(impaired)
## [1] "logical"
⚠️ Vectors must be a single type If you mix numbers
and characters in c(), R silently converts everything to
character — a process called coercion. Try c(1, 2,
“three”) and check its class. This is a common source of
confusion when reading messy data files.
Use square brackets [ ] to extract elements by position
(index starts at 1, not 0):
temperature[1] # first element (surface temperature)
## [1] 22.1
temperature[6] # sixth element
## [1] 11
temperature[1:3] # elements 1 through 3
## [1] 22.1 21.8 20.5
temperature[c(1, 4, 8)] # specific positions
## [1] 22.1 18.2 9.8
You can also use a logical condition to filter:
# Which depths have temperatures below 12°C? (hypolimnion)
depth[temperature < 12]
## [1] 5 6 7
# The actual temperatures in the hypolimnion
temperature[temperature < 12]
## [1] 11.0 10.2 9.8
📝 Try it: vector arithmetic Vectors support
element-wise arithmetic. Convert temperature from Celsius to Fahrenheit:
temperature * 9/5 + 32. Create a new object called
temp_f and print it. Notice R applies the conversion to
every element at once — no loop needed.
A data frame is a collection of equal-length vectors arranged as columns — exactly like a spreadsheet, but with R’s statistical power behind it. Each column is a variable; each row is an observation.
# pHake Lake: a simulated alpine lake used in EA030 labs
# Data represent a summer thermal stratification profile
phake_depth <- c(0, 0.5, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 14)
phake_temp <- c(22.4, 22.1, 21.8, 20.9, 19.1, 16.5,
13.2, 11.4, 10.8, 10.4, 10.1, 10.0, 9.9, 9.8)
# Confirm vectors are the same length before combining
length(phake_depth) == length(phake_temp)
## [1] TRUE
# Build the data frame
phake <- data.frame(
depth_m = phake_depth,
temp_c = phake_temp
)
# Preview the top six rows
head(phake)
## depth_m temp_c
## 1 0.0 22.4
## 2 0.5 22.1
## 3 1.0 21.8
## 4 2.0 20.9
## 5 3.0 19.1
## 6 4.0 16.5
# Structure: types, dimensions, a few values
str(phake)
## 'data.frame': 14 obs. of 2 variables:
## $ depth_m: num 0 0.5 1 2 3 4 5 6 7 8 ...
## $ temp_c : num 22.4 22.1 21.8 20.9 19.1 16.5 13.2 11.4 10.8 10.4 ...
# Quick summary statistics for every column
summary(phake)
## depth_m temp_c
## Min. : 0.000 Min. : 9.80
## 1st Qu.: 2.250 1st Qu.:10.18
## Median : 5.500 Median :12.30
## Mean : 5.821 Mean :14.89
## 3rd Qu.: 8.750 3rd Qu.:20.45
## Max. :14.000 Max. :22.40
$The $ operator drills into a data frame column and
returns it as a vector:
phake$depth_m # depth column as a vector
## [1] 0.0 0.5 1.0 2.0 3.0 4.0 5.0 6.0 7.0 8.0 9.0 10.0 12.0 14.0
mean(phake$temp_c) # mean temperature across all depths
## [1] 14.88571
📚 The data frame as the unit of analysis Keeping
all your variables together in a single data frame — rather than as
separate loose vectors — is good practice. It prevents length
mismatches, makes your code readable, and is required by most modelling
and plotting functions that use the data = argument.
We can add new variables directly to the data frame:
# Convert temperature to Fahrenheit and add as a new column
phake$temp_f <- phake$temp_c * 9 / 5 + 32
# A depth class column: epilimnion vs. hypolimnion
phake$layer <- ifelse(phake$temp_c > 15, "Epilimnion", "Hypolimnion")
head(phake)
## depth_m temp_c temp_f layer
## 1 0.0 22.4 72.32 Epilimnion
## 2 0.5 22.1 71.78 Epilimnion
## 3 1.0 21.8 71.24 Epilimnion
## 4 2.0 20.9 69.62 Epilimnion
## 5 3.0 19.1 66.38 Epilimnion
## 6 4.0 16.5 61.70 Epilimnion
Before fitting any model, always explore your data. Summary statistics reveal the central tendency, spread, and shape of your variables.
# Central tendency
mean(phake$temp_c)
## [1] 14.88571
median(phake$temp_c)
## [1] 12.3
# Spread
sd(phake$temp_c) # standard deviation
## [1] 5.278091
var(phake$temp_c) # variance
## [1] 27.85824
range(phake$temp_c) # min and max
## [1] 9.8 22.4
IQR(phake$temp_c) # interquartile range
## [1] 10.275
# The all-in-one function
summary(phake$temp_c)
## Min. 1st Qu. Median Mean 3rd Qu. Max.
## 9.80 10.18 12.30 14.89 20.45 22.40
tapply()The tapply() function computes a statistic on a variable
within groups — here the epilimnion versus hypolimnion layers
we defined above:
tapply(phake$temp_c, phake$layer, mean)
## Epilimnion Hypolimnion
## 20.46667 10.70000
tapply(phake$temp_c, phake$layer, sd)
## Epilimnion Hypolimnion
## 2.279181 1.142679
📌 Check your data before you model it Always run
summary() and look at your data frame with
head() and str() before doing any analysis.
Watch for: impossible values (temperature of −99 is usually a missing
data code), unexpected NA values, and variables stored as
the wrong type (numbers stored as characters).
R’s base graphics system produces publication-ready figures directly
from data frames and vectors. The key function is
plot().
plot(phake$temp_c, phake$depth_m,
xlab = "Temperature (°C)",
ylab = "Depth (m)",
main = "pHake Lake — Summer Thermal Profile",
pch = 16, # solid circle symbols
col = "steelblue")
Limnologists and oceanographers always plot depth increasing
downward on the y-axis, matching the physical orientation of
the water column. Reversing the y-axis requires passing a reversed
ylim:
# Classic depth profile: depth on y (inverted), temperature on x
plot(x = phake$temp_c,
y = phake$depth_m,
xlim = c(8, 25),
ylim = rev(range(phake$depth_m)), # reverse: deeper = lower
xlab = "Water Temperature (°C)",
ylab = "Depth (m)",
main = "pHake Lake: Thermal Stratification, Summer",
type = "b", # "b" = both points and lines
pch = 16,
col = "steelblue3",
lwd = 2,
las = 1) # horizontal axis labels
# Add a horizontal line marking the thermocline
abline(h = 4, lty = 2, col = "firebrick", lwd = 1.5)
text(x = 23, y = 4.5, labels = "Thermocline", col = "firebrick", cex = 0.85)
| Parameter | Meaning | Example values |
|---|---|---|
xlab, ylab |
Axis labels | "Temperature (°C)" |
main |
Plot title | use sparingly — use figure captions instead |
pch |
Point symbol | 16 = filled circle, 17 = triangle,
1 = open circle |
col |
Color | "steelblue", "#2d5a3d",
rgb(0.2, 0.4, 0.3) |
lwd |
Line width | 1 (default), 2, 3 |
lty |
Line type | 1 = solid, 2 = dashed, 3 =
dotted |
cex |
Character expansion (size) | 1 (default), 1.5 = 50% larger |
las |
Axis label orientation | 1 = horizontal (recommended) |
type |
Plot type | "p" = points, "l" = lines,
"b" = both |
📈 Science figures vs. Excel defaults Excel defaults
are designed for business presentations, not scientific communication.
In scientific figures: omit the box frame (bty = “n”),
always label axes with units, avoid decorative colors, and let a figure
caption — not a title — describe what the reader should take away. R
makes it easy to adopt these conventions.
par(mfrow = c(1, 2)) # two panels side by side
hist(phake$temp_c,
breaks = 8,
col = "#d4e6da",
border = "#1e3a2f",
xlab = "Temperature (°C)",
main = "Temperature Distribution")
boxplot(temp_c ~ layer, data = phake,
col = c("#8aad96", "#4a7c5e"),
ylab = "Temperature (°C)",
xlab = "Lake Layer",
las = 1,
main = "Epilimnion vs. Hypolimnion")
par(mfrow = c(1, 1)) # reset to single panel
The linear model is the cornerstone of quantitative analysis in environmental science. Whether you are testing for a treatment effect (ANOVA) or estimating a continuous relationship (regression), the underlying framework is the same:
\[y \sim \alpha + \beta \cdot x + \varepsilon\]
\(\alpha\) is the intercept, \(\beta\) is the slope, and \(\varepsilon\) is the residual error. In R,
~ means “is modelled as a function of.”
lm()Temperature decreases with depth in a stratified lake. We can estimate this relationship formally:
# Fit temperature as a function of depth
temp_model <- lm(temp_c ~ depth_m, data = phake)
# Inspect the coefficients
coef(temp_model)
## (Intercept) depth_m
## 21.271919 -1.097017
The intercept is the estimated surface temperature (depth = 0); the slope is the average change in temperature per additional metre of depth.
summary(temp_model)
##
## Call:
## lm(formula = temp_c ~ depth_m, data = phake)
##
## Residuals:
## Min 1Q Median 3Q Max
## -3.2898 -1.8965 0.4087 1.5630 3.8863
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) 21.2719 1.0190 20.875 8.46e-11 ***
## depth_m -1.0970 0.1416 -7.748 5.21e-06 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 2.242 on 12 degrees of freedom
## Multiple R-squared: 0.8334, Adjusted R-squared: 0.8195
## F-statistic: 60.04 on 1 and 12 DF, p-value: 5.207e-06
Key values to read:
⚠️ Does a straight line make sense here? Lake temperature vs. depth is not actually linear — there is an abrupt thermocline. A linear model here simplifies reality. Always plot your data and inspect residuals before trusting a model summary. The R² being high does not mean the model is appropriate.
plot(x = phake$depth_m,
y = phake$temp_c,
xlab = "Depth (m)",
ylab = "Temperature (°C)",
main = "Temperature vs. Depth — Linear Model Fit",
pch = 16, col = "steelblue3", las = 1)
abline(temp_model, col = "firebrick", lwd = 2)
# Add the R² value as a text annotation
r2 <- round(summary(temp_model)$r.squared, 3)
text(x = 1, y = 11,
labels = paste0("R² = ", r2),
col = "firebrick", cex = 0.9)
# Coefficients
coef(temp_model)["(Intercept)"]
## (Intercept)
## 21.27192
coef(temp_model)["depth_m"]
## depth_m
## -1.097017
# Fitted values (predicted temperatures at each observed depth)
head(fitted(temp_model))
## 1 2 3 4 5 6
## 21.27192 20.72341 20.17490 19.07789 17.98087 16.88385
# Residuals (observed minus fitted)
head(residuals(temp_model))
## 1 2 3 4 5 6
## 1.1280812 1.3765895 1.6250979 1.8221146 1.1191314 -0.3838519
# Confidence intervals for the coefficients
confint(temp_model)
## 2.5 % 97.5 %
## (Intercept) 19.051657 23.4921804
## depth_m -1.405495 -0.7885387
📌 Getting help To see what arguments a function
accepts, type ?lm or help(lm) in the console.
The Examples section at the bottom of any help page is
the fastest way to learn — paste it in and run it, then adapt it to your
data.
pHake Lake is a simulated alpine lake used in EA030 field laboratories. In this case study we work through a complete analysis workflow — from raw data to interpreted figure — using the seasonal temperature profile collected by a data logger.
The data below represent temperature readings taken at standard depth intervals during summer stratification. All data are embedded directly in this document — no external file needed.
# Full summer dataset — three replicate casts averaged
phake_full <- data.frame(
depth_m = c(0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5,
4.0, 4.5, 5.0, 5.5, 6.0, 7.0, 8.0, 9.0,
10.0, 11.0, 12.0, 13.0, 14.0),
temp_c = c(22.4, 22.2, 22.0, 21.7, 21.2, 20.3, 19.0, 17.4,
15.3, 13.1, 11.5, 10.9, 10.6, 10.3, 10.1, 10.05,
10.0, 9.98, 9.95, 9.92, 9.90),
do_mgl = c(9.1, 9.0, 9.0, 8.9, 8.8, 8.5, 8.2, 7.8,
7.1, 6.5, 5.9, 5.2, 4.8, 4.5, 4.3, 4.2,
4.1, 4.1, 4.0, 4.0, 3.9) # dissolved oxygen (mg/L)
)
str(phake_full)
## 'data.frame': 21 obs. of 3 variables:
## $ depth_m: num 0 0.5 1 1.5 2 2.5 3 3.5 4 4.5 ...
## $ temp_c : num 22.4 22.2 22 21.7 21.2 20.3 19 17.4 15.3 13.1 ...
## $ do_mgl : num 9.1 9 9 8.9 8.8 8.5 8.2 7.8 7.1 6.5 ...
summary(phake_full)
## depth_m temp_c do_mgl
## Min. : 0.000 Min. : 9.90 Min. :3.900
## 1st Qu.: 2.500 1st Qu.:10.05 1st Qu.:4.200
## Median : 5.000 Median :11.50 Median :5.900
## Mean : 5.857 Mean :14.66 Mean :6.281
## 3rd Qu.: 9.000 3rd Qu.:20.30 3rd Qu.:8.500
## Max. :14.000 Max. :22.40 Max. :9.100
The thermocline is the depth zone of maximum temperature change — where the water column transitions from warm, well-mixed epilimnion to cold, stable hypolimnion. We can find it by computing the temperature gradient (change per metre):
# Calculate temperature gradient between consecutive depths
delta_temp <- diff(phake_full$temp_c) # differences between adjacent rows
delta_depth <- diff(phake_full$depth_m)
gradient <- delta_temp / delta_depth # °C per metre
# Depth of maximum gradient (most negative = steepest cooling)
thermo_depth <- phake_full$depth_m[which.min(gradient)]
cat("Thermocline centre depth:", thermo_depth, "m\n")
## Thermocline centre depth: 4 m
cat("Maximum gradient:", round(min(gradient), 2), "°C/m\n")
## Maximum gradient: -4.4 °C/m
par(mar = c(4.5, 4.5, 2, 1), las = 1, cex = 0.95)
plot(x = phake_full$temp_c,
y = phake_full$depth_m,
xlim = c(8, 25),
ylim = rev(range(phake_full$depth_m)),
xlab = "Water Temperature (°C)",
ylab = "Depth (m)",
main = "pHake Lake: Thermal Profile",
type = "b",
pch = 16,
col = "steelblue3",
lwd = 2,
bty = "l") # only left and bottom axes
# Shade epilimnion vs. hypolimnion
rect(xleft = 8, xright = 25,
ybottom = thermo_depth + 1, ytop = 14,
col = rgb(0.18, 0.36, 0.56, 0.08), border = NA)
# Mark thermocline
abline(h = thermo_depth, lty = 2, col = "firebrick", lwd = 1.5)
text(x = 23.5, y = thermo_depth - 0.6,
labels = paste0("Thermocline\n(", thermo_depth, " m)"),
col = "firebrick", cex = 0.75, adj = 1)
# Add layer labels
text(x = 22.5, y = 1, labels = "Epilimnion", cex = 0.8, col = "#1e3a2f")
text(x = 22.5, y = 12, labels = "Hypolimnion", cex = 0.8, col = "#1e3a2f")
Dissolved oxygen (DO) often mirrors temperature in stratified lakes — warm surface waters lose oxygen at depth. Plotting both on the same figure requires a second axis:
par(mar = c(4.5, 4.5, 2, 4.5), las = 1, cex = 0.9)
# --- Temperature (primary x-axis, bottom) ---
plot(x = phake_full$temp_c,
y = phake_full$depth_m,
xlim = c(8, 25),
ylim = rev(range(phake_full$depth_m)),
xlab = "Temperature (°C)",
ylab = "Depth (m)",
main = "pHake Lake: Temperature & Dissolved Oxygen",
type = "b", pch = 16, col = "steelblue3", lwd = 2, bty = "n")
# --- Dissolved oxygen (secondary x-axis, top) ---
par(new = TRUE)
plot(x = phake_full$do_mgl,
y = phake_full$depth_m,
xlim = c(3, 10),
ylim = rev(range(phake_full$depth_m)),
xlab = "", ylab = "", axes = FALSE,
type = "b", pch = 17, col = "darkorange3", lwd = 2)
axis(side = 3, col = "darkorange3", col.axis = "darkorange3")
mtext("Dissolved Oxygen (mg/L)", side = 3, line = 2.5, col = "darkorange3", cex = 0.85)
legend("bottomright",
legend = c("Temperature (°C)", "Dissolved O₂ (mg/L)"),
col = c("steelblue3", "darkorange3"),
lty = 1, pch = c(16, 17), lwd = 2,
bty = "n", cex = 0.8)
A simple linear model misses the non-linearity of thermal stratification. Below we compare R² for depth zones to illustrate how the relationship differs across layers:
epi <- phake_full[phake_full$depth_m <= thermo_depth, ]
hypo <- phake_full[phake_full$depth_m > thermo_depth, ]
# Epilimnion model
lm_epi <- lm(temp_c ~ depth_m, data = epi)
# Hypolimnion model
lm_hypo <- lm(temp_c ~ depth_m, data = hypo)
cat("Epilimnion slope: ", round(coef(lm_epi)["depth_m"], 3), "°C/m\n")
## Epilimnion slope: -1.673 °C/m
cat("Hypolimnion slope: ", round(coef(lm_hypo)["depth_m"], 3), "°C/m\n")
## Hypolimnion slope: -0.214 °C/m
cat("Epilimnion R²: ", round(summary(lm_epi)$r.squared, 3), "\n")
## Epilimnion R²: 0.864
cat("Hypolimnion R²: ", round(summary(lm_hypo)$r.squared, 3), "\n")
## Hypolimnion R²: 0.545
The steeply negative slope in the epilimnion captures the thermocline gradient; the near-zero slope below confirms the isothermal hypolimnion.
<- to
assign names. Objects persist in your Environment until you remove or
overwrite them.
c(), index with [ ],
filter with logical conditions.
data = argument in plot() and
lm() — it makes code readable and avoids length errors.
summary(),
str(), and head() on every new dataset before
fitting anything.
y ~ x. The
tilde means “is modelled as a function of.” The lm() output
object holds coefficients, residuals, R², and everything else.
xlab =
“Temperature (°C)” — never leave axes unlabeled in scientific
figures.
ylim =
rev(range(depth)) so deeper = lower on the page.
ggplot2 for layered, publication-quality figures