This document provides a practical introduction to performing statistical analyses in R. Throughout the course, we discuss a range of statistical methods and concepts. Here, we will translate these concepts into practice by looking at the R code used to perform the corresponding analyses.
The analyses will be demonstrated using an example dataset: apartments. For each analysis, we will walk through the relevant R code step by step, explain what the different commands do, and look at the resulting output. We will also discuss how to interpret the results and how the output relates to the statistical concepts covered in the course.
The goal is not to memorise the code, but to understand the general workflow and learn how R can be used as a tool for statistical analysis. The code examples can serve as a reference when you work with your own data. In many cases, the same code can be adapted to a different dataset by changing the relevant variable names or other arguments.
As you work through the examples, it is useful to keep the statistical reasoning in mind: What question are we trying to answer? Which analysis is appropriate? How do we perform it in R? And how do we interpret the results? These questions are at least as important as the R code itself.
The examples in this document are intended as a practical guide that you can return to when performing similar analyses on your own data.
Throughout this course, the APARTMENT dataset will be used to show how to perform certain statistical analyses in R. The APARTMENT dataset contains 117 observations on apartments sold in Antwerp. The variables recorded in the dataset are:
Since the APARTMENT dataset is recorded in a text file (extension
.txt), we can use the read.table() command in R to load the
dataset into the R environment:
apartment <- read.table("apartment.txt", header=T)
Here, apartment is the name we give to our dataset. The
argument header = TRUE tells R that the first row of the
file contains the variable names.
Once the data have been read into R, the dataset is stored as an
object called apartment. We can then use this object in our
analyses.
There are several simple ways to inspect a dataset.
The View() function opens the dataset in a
spreadsheet-like window:
View(apartment)
This is useful when you want to look through the data and get a general idea of its structure.
You can also use head() to look at the first rows of the
dataset:
head(apartment)
#> price priceclass age feats area tax sqm feats2
#> 1 90000 2 16 2 1 731 63.000 1
#> 2 86000 2 11 2 1 653 62.325 1
#> 3 92500 2 45 2 1 553 47.250 1
#> 4 89900 2 9 2 1 566 65.880 1
#> 5 85000 1 41 1 1 600 53.550 1
#> 6 87600 2 8 1 1 650 52.020 1
By default, head() shows the first six rows. You can specify the number of rows you want to see:
head(apartment,10)
#> price priceclass age feats area tax sqm feats2
#> 1 90000 2 16 2 1 731 63.000 1
#> 2 86000 2 11 2 1 653 62.325 1
#> 3 92500 2 45 2 1 553 47.250 1
#> 4 89900 2 9 2 1 566 65.880 1
#> 5 85000 1 41 1 1 600 53.550 1
#> 6 87600 2 8 1 1 650 52.020 1
#> 7 89000 2 12 2 1 591 78.570 1
#> 8 87000 2 23 1 1 599 57.600 1
#> 9 72000 1 7 1 1 436 47.250 1
#> 10 81000 1 33 2 1 673 61.425 1
Similarly, tail() shows the last rows of the dataset:
tail(apartment)
#> price priceclass age feats area tax sqm feats2
#> 112 215000 3 6 5 1 1193 119.880 2
#> 113 215000 3 3 6 1 1635 131.445 2
#> 114 215000 3 4 6 1 1487 128.160 2
#> 115 159900 3 24 5 1 1265 109.800 2
#> 116 129900 3 25 5 1 1232 123.435 2
#> 117 184400 3 40 6 0 915 101.250 2
These commands are particularly useful for quickly checking whether the data have been read into R correctly.
Variables (columns) can be extracted from a dataset using the
$ operator.
For example, the apartment dataset contain the variable ‘price’, so we can extract it using:
apartment$price
#> [1] 90000 86000 92500 89900 85000 87600 89000 87000 72000 81000
#> [11] 79900 75000 69000 67000 61900 93900 82000 70000 54000 107000
#> [21] 66000 58000 69900 103000 158000 144900 115500 111000 113900 99500
#> [31] 99500 97500 97500 102000 102000 92200 70000 72000 72500 67000
#> [41] 123900 112500 110000 105000 95500 93400 87500 88900 80500 75000
#> [51] 75900 73000 71000 97500 78000 77000 62000 72500 60000 116000
#> [61] 110900 112900 105000 104500 102000 97500 95000 94000 92000 94500
#> [71] 87400 87200 87000 86900 76600 73900 145000 125000 118000 155300
#> [81] 130000 125000 120000 108000 104900 85500 210000 105000 208000 199900
#> [91] 190000 180000 169500 125000 135000 129500 96000 74900 73100 83500
#> [101] 75500 72900 77300 100000 156000 137500 127000 123500 117000 133000
#> [111] 205000 215000 215000 215000 159900 129900 184400
This returns the values of the variable price.
The $ operator can also be used when performing
calculations or creating graphs. For example, we can calculate the mean
age with:
mean(apartment$price)
#> [1] 106273.5
In general, the syntax for extracting a variable is:
Syntax:
data$variableWhere:
- data: Is the name of your dataset
- variable: Is the name of the variable you want to access
These basic commands are enough to start exploring a dataset in R. Once the data are loaded and we know how to access the variables, we can move on to describing and visualising the data.
The first step in any statistical analysis is to explore and describe the data. Before applying statistical tests or fitting statistical models, it is important to develop a good understanding of the data you are working with.
This initial exploration helps us to understand the structure and characteristics of the dataset. We can, for example, look at the distributions of variables, identify patterns or differences between groups, and detect unusual observations or potential data problems. It also helps us to decide which statistical analyses may be appropriate for the data.
To explore our data, we use a combination of visualisations and summary statistics. Graphs allow us to see patterns and distributions in the data, while numerical summaries provide useful information about the central tendency, variability, and other characteristics of our variables.
In this section, we will use R to create several commonly used visualisations and calculate descriptive statistics. The specific methods we use will depend on the type of variable we are interested in and the question we want to answer.
Before calculating summary statistics or creating graphs, it is important to check what type of variable we are working with. The type of variable determines which analyses and visualisations are appropriate.
We can use the class() function to check what type of
variable we are working with:
class(apartment$price)
#> [1] "numeric"
In this example, R returns: [1] "numeric". This tells us
that price is a numerical variable.
For a categorical variable, we generally want R to recognise the variable as a factor. We can check this in the same way:
class(apartment$area)
#> [1] "integer"
If the variable is not recognised as a factor by R, we can convert it
using factor(), and check the results again:
apartment$area <- factor(apartment$area)
class(apartment$area)
#> [1] "factor"
It is important to make sure that variables have the correct type before starting the analysis. For example, a variable representing groups or categories should generally be treated as a factor rather than as a numerical variable.
When we convert a variable to a factor, R uses the values in the
dataset as the levels of the factor. We can inspect these levels using
levels():
levels(apartment$area)
#> [1] "0" "1"
Sometimes the levels are not very informative or are difficult to understand. We can change them to more meaningful labels using the same function. For example, the variable area in the dataset contains the levels 0 and 1, representing non-residential and residential areas. We can replace these numbers with more understandable labels:
levels(apartment$area) <- c("Non-residential","Residential")
levels(apartment$area)
#> [1] "Non-residential" "Residential"
The order of the labels corresponds to the order of the existing factor levels. Therefore, it is important to check the original levels before changing them.
In order to avoid making mistakes and changing variables permanently in your dataset, it is best to create a new variable in your dataset that contains the levels that you want. For example:
apartment$priceclass2 <- factor (apartment$priceclass,
levels = c(1,2,3),
labels = c("low", "medium", "high"))
apartment$priceclass2
#> [1] medium medium medium medium low medium medium medium low low
#> [11] low low low low low medium low low low medium
#> [21] low low low medium high high medium medium medium medium
#> [31] medium medium medium medium medium medium low low low low
#> [41] medium medium medium medium medium medium medium medium low low
#> [51] low low low medium low low low low low medium
#> [61] medium medium medium medium medium medium medium medium medium medium
#> [71] medium medium medium medium low low high medium medium high
#> [81] high medium medium medium medium medium high medium high high
#> [91] high high high medium high high medium low low low
#> [101] low low low medium high high high medium medium high
#> [111] high high high high high high high
#> Levels: low medium high
This creates a new categorical variable called
priceclass2 in the dataset apartment, which
contains the levels low, medium, and high as defined by the categories
1,2, and 3 in the original variable priceclass.
For categorical variables, a useful first step is to create a
frequency table. The table() function shows how many
observations belong to each category:
table(apartment$area)
#>
#> Non-residential Residential
#> 39 78
To calculate the percentages, we can divide the frequencies by the total number of observations:
table(apartment$area) / length(apartment$area)
#>
#> Non-residential Residential
#> 0.3333333 0.6666667
Alternatively, we can use prop.table():
prop.table(table(apartment$area))
#>
#> Non-residential Residential
#> 0.3333333 0.6666667
The result gives the percentage of observations in each category.
We can also calculate the cumulative percentage using
cumsum():
cumsum(prop.table(table(apartment$area)))
#> Non-residential Residential
#> 0.3333333 1.0000000
We can combine the frequency, percentage, and cumulative percentage into one table:
freq <- table(apartment$area)
percent <- prop.table(table(apartment$area))
cumulative_percent <- cumsum(percent)
freq_table <- data.frame(
Frequency = as.vector(freq),
Percentage = as.vector(percent),
Cumulative_Percentage = cumulative_percent
)
freq_table
#> Frequency Percentage Cumulative_Percentage
#> Non-residential 39 0.3333333 0.3333333
#> Residential 78 0.6666667 1.0000000
The cumulative percentage is particularly useful when the categories have a meaningful order.
A barplot is useful for visualising the frequencies of a categorical variable. In base R, we can create one directly from a frequency table:
barplot(table(apartment$area))
The height of each bar represents the number of observations in that category.
We can customise the appearance of the barplot by adding different
arguments to the barplot() function:
barplot(
table(apartment$area),
main = "Barplot of the variable AREA",
xlab = "AREA",
ylab = "Frequency",
col="darkred"
)
The different arguments control different aspects of the graph:
- main adds a title to the graph
- xlab specifies the label of the x-axis.
- ylab specifies the label of the y-axis.
- col specifies the colour of the bars.
For example, if we want to use a different colour:
barplot(
table(apartment$area),
main = "Barplot of the variable AREA",
xlab = "AREA",
ylab = "Frequency",
col="lightblue"
)
You can use many different colour names in R, such as “lightblue”, “lightgreen”, “orange”, “pink”, “grey”, or “steelblue”. You find lists online of all the colors that are recognized by R.
You can also specify a different colour for each bar by providing a vector of colours:
barplot(
table(apartment$area),
main = "Barplot of the variable AREA",
xlab = "AREA",
ylab = "Frequency",
col=c("lightblue","lightgreen")
)
The first colour is used for the first bar, the second colour for the second bar, and so on.
You do not need to specify all of these arguments. If you leave an argument out, R will use its default setting. As you become more familiar with R, you can explore additional options for controlling the appearance of your graphs. For now, the arguments introduced above are sufficient for creating clear and informative barplots.
For a numerical variable, we are often interested in its distribution. A histogram shows how the observations are distributed across different values or intervals.
For example:
hist(apartment$price)
We can also add labels to the histogram, change the color, …:
hist(apartment$price,
main="Distribution of Price",
xlab="Price",
ylab="Frequency",
col="darkred")
A histogram can help us assess the shape of a distribution and identify features such as skewness, gaps, or unusually high or low values.
Another useful way to visualise a numerical variable is with a boxplot:
boxplot(apartment$price)
We can add labels and colours in the same way:
boxplot(apartment$price,
main = "Distribution of Price",
ylab = "Price",
col="yellow")
A boxplot provides a compact summary of the distribution. It shows the median, the quartiles, and the overall spread of the data. Individual observations that are far from the rest of the data may also be displayed as potential outliers.
Boxplots can also be used to compare a numerical variable between groups. For example, if we want to compare price between residential and non-residential areas:
boxplot(apartment$price~apartment$area,
main = "Distribution of Price by Area",
ylab = "Price",
xlab="Area",
col="darkgreen")
Here, the ~ notation tells R that we want to examine
price by area.
Graphs provide a visual overview of our data, but we can also describe a numerical variable using summary statistics. These statistics give us information about the centre, spread, and range of the distribution.
Measures of central tendency describe the centre of a distribution. The two most commonly used measures are the mean and the median.
The mean is calculated by adding all observations and dividing by the
number of observations. In R, we use the mean()
function:
mean(apartment$price)
#> [1] 106273.5
The mean can be sensitive to extreme values. If a variable contains very large or very small observations, the mean can be pulled towards these values.
The median is the middle value when the observations are ordered from
smallest to largest. In R, we use the median()
function:
median(apartment$price)
#> [1] 96000
The median is less affected by extreme values than the mean and can therefore be a useful measure of the centre when a distribution is skewed.
Measures of spread tell us how much the observations differ from each other.
The variance measures how much the observations vary around the mean.
It is based on the squared deviations from the mean. In R, we use
var():
var(apartment$price)
#> [1] 1447322999
Because the deviations are squared, the variance is expressed in
squared units of the original variable. This can make it difficult to
interpret directly. The standard deviation is closely related to the
variance, i.e. the square root of the variance, and is often easier to
interpret because it is expressed in the same units as the original
variable. The standard deviation gives an indication of the typical
distance of observations from the mean. In R, we use
sd():
sd(apartment$price)
#> [1] 38043.7
The minimum and maximum give us the smallest and largest observed values.
min(apartment$price)
#> [1] 54000
max(apartment$price)
#> [1] 215000
We can also obtain both values at the same time using range():
range(apartment$price)
#> [1] 54000 215000
The result gives the minimum followed by the maximum. The range describes the distance between the minimum and maximum values. It can be calculated by subtracting the minimum from the maximum:
max(apartment$price)-min(apartment$price)
#> [1] 161000
The range gives a simple indication of the total spread of the data, but it can be strongly affected by extreme observations.
Quartiles divide the ordered data into four parts.
In R, we can calculate the quartiles using
quantile():
quantile(apartment$price)
#> 0% 25% 50% 75% 100%
#> 54000 78000 96000 120000 215000
By default, R returns the minimum, Q1, median, Q3, and maximum. We can also request specific quantiles. For example:
quantile(apartment$price, probs = c(0.25, 0.50, 0.75))
#> 25% 50% 75%
#> 78000 96000 120000
Here, probs specifies which quantiles we want to calculate. 0.25 corresponds to the 25th percentile (Q1), 0.50 to the median (Q2), and 0.75 to the 75th percentile (Q3).
The interquartile range (IQR) describes the spread of the middle 50% of the observations. It is calculated as IQR = Q3 − Q1. In R, we can calculate it directly using:
IQR(apartment$price)
#> [1] 42000
The IQR is less affected by extreme values than the range and is therefore a useful measure of spread, particularly for skewed distributions.
Instead of calculating each statistic separately, we can use the
summary() function:
summary(apartment$price)
#> Min. 1st Qu. Median Mean 3rd Qu. Max.
#> 54000 78000 96000 106274 120000 215000
For a numerical variable, summary() provides the
minimum, first quartile, median, mean, third quartile, and maximum. This
is a convenient way to get a quick numerical overview of a variable.
When we have two numerical variables, we can investigate whether they are related using a correlation. The Pearson correlation coefficient, usually denoted by r, measures the strength and direction of a linear relationship between two numerical variables.
The correlation coefficient ranges from -1 to +1:
For example, we can calculate the correlation between two numerical
variables using cor():
cor(apartment$price,apartment$tax)
#> [1] 0.8768248
We can also visualise the relationship between two numerical variables using a scatterplot:
plot(apartment$price,
apartment$tax,
col="darkred",
pch=19,
xlab="PRICE",
ylab="TAX")
Here, pch = 19 specifies that the observations should be
displayed as solid circles. There are many different plotting symbols
available in base R. For example:
plot(apartment$price,apartment$tax,pch=1)
plot(apartment$price,apartment$tax,pch=23)
plot(apartment$price,apartment$tax,pch=17)
You can find an overview online of the different values for different shapes of points.
The scatterplot is an important complement to the correlation coefficient because it allows us to see the relationship between the variables. A correlation coefficient alone does not tell us everything about the relationship and can, for example, hide non-linear patterns or the influence of unusual observations.
In statistics, we are often interested in learning something about a population, but we usually do not have access to data from the entire population. Instead, we collect a sample and use this sample to learn about the population.
For example, suppose we are interested in the average value of a variable in a population. We could calculate the mean of a sample and use this sample mean as an estimate of the population mean.
An important question is: How much would the sample mean vary if we repeatedly took different samples from the same population? The Central Limit Theorem (CLT) helps us answer this question.
The Central Limit Theorem states, roughly, that when we repeatedly take random samples of a sufficiently large size from a population, the distribution of the sample means will be approximately normal, even if the original population itself is not normally distributed. There are therefore two different distributions to keep in mind:
The CLT is about the second distribution. We can illustrate this with a simulation in R. The details of the simulation are not important to remember. The purpose is simply to visualise what happens when we repeatedly take samples and calculate their means.
Let’s start with a population that is clearly not normally distributed. For example, we can generate observations from an exponential distribution:
set.seed(123)
population <- rexp(100000, rate = 1)
We can visualise this population:
hist(
population,
main = "Population distribution",
xlab = "Value",
col = "lightblue"
)
As you can see, the population distribution is strongly right-skewed rather than normally distributed.
Now, imagine that we repeatedly take samples from this population. For each sample, we calculate the mean. We can simulate this process using the following code:
sample_size <- 30
number_of_samples <- 10000
sample_means <- replicate(
number_of_samples,
mean(sample(population, sample_size))
)
Here, we have taken 10,000 samples, each containing 30 observations, and calculated the mean of each sample. The object sample_means therefore contains 10,000 sample means.
We can now look at the distribution of these means:
hist(
sample_means,
main = "Sampling distribution of the mean",
xlab = "Sample mean",
col = "lightblue"
)
Although the original population was strongly skewed, the distribution of the sample means is approximately normal.
This is the central idea behind the Central Limit Theorem.
What happens with smaller samples? The sample size is important. Let’s repeat the simulation using a much smaller sample size, for example 5 observations per sample:
sample_size <- 5
sample_means_small <- replicate(
number_of_samples,
mean(sample(population, sample_size)))
We can again look at the distribution of the sample means:
hist(
sample_means_small,
main = "Sampling distribution (n = 5)",
xlab = "Sample mean",
col = "lightblue"
)
With such a small sample size, the distribution of the sample means is less clearly normal and retains more of the skewness of the original population.
If we increase the sample size:
sample_size <- 100
sample_means_large <- replicate(
number_of_samples,
mean(sample(population, sample_size))
)
and plot the resulting means:
hist(
sample_means_large,
main = "Sampling distribution (n = 100)",
xlab = "Sample mean",
col = "lightblue"
)
The distribution of the sample means becomes even more clearly approximately normal.
A sample mean gives us an estimate of the population mean. However, because we usually work with a sample rather than the entire population, our estimate is subject to sampling variability. If we were to take a different sample, we would generally obtain a somewhat different sample mean.
Rather than reporting only the sample mean, we can therefore also give an interval of plausible values for the population mean. This is called a confidence interval.
A commonly used confidence level is 95%. A 95% confidence interval gives us an interval constructed from the sample data that, under repeated sampling and the assumptions of the method, would contain the true population mean in approximately 95% of repeated samples.
Suppose we want to estimate the population mean of price based on the
observations in our apartment dataset. R provides a convenient way to
calculate a confidence interval for a mean using the
t.test() function:
t.test(apartment$price)
#>
#> One Sample t-test
#>
#> data: apartment$price
#> t = 30.216, df = 116, p-value < 2.2e-16
#> alternative hypothesis: true mean is not equal to 0
#> 95 percent confidence interval:
#> 99307.36 113239.65
#> sample estimates:
#> mean of x
#> 106273.5
The relevant part of the output for our purposes is:
95 percent confidence interval: 99307.36 113239.65
sample estimates: mean of x 106273.5
We estimate the population mean to be 106273.5, with a 95% confidence interval from 99307.36 to 113239.65. In other words, the interval gives us a range of plausible values for the population mean based on our sample.
It is important to be precise about what the 95% refers to. The confidence level describes the method used to construct the interval, not the probability that the particular interval we have calculated contains the population mean. In repeated sampling, approximately 95% of intervals constructed using this method would contain the true population mean.
A 95% confidence interval is common, but we can choose other
confidence levels. For example, t.test() can calculate a
99% confidence interval:
t.test(apartment$price, conf.level = 0.99)
#>
#> One Sample t-test
#>
#> data: apartment$price
#> t = 30.216, df = 116, p-value < 2.2e-16
#> alternative hypothesis: true mean is not equal to 0
#> 99 percent confidence interval:
#> 97062.54 115484.47
#> sample estimates:
#> mean of x
#> 106273.5
A higher confidence level produces a wider interval. For example, a 99% confidence interval will generally be wider than a 95% confidence interval because we want to be more confident that the interval captures the population mean.
Similarly, a 90% confidence interval will generally be narrower:
t.test(apartment$price, conf.level = 0.90)
#>
#> One Sample t-test
#>
#> data: apartment$price
#> t = 30.216, df = 116, p-value < 2.2e-16
#> alternative hypothesis: true mean is not equal to 0
#> 90 percent confidence interval:
#> 100441.7 112105.3
#> sample estimates:
#> mean of x
#> 106273.5
The width of a confidence interval is influenced by the sample size. Larger samples generally produce more precise estimates and therefore narrower confidence intervals.
This connects directly to the Central Limit Theorem and sampling variability discussed above: as the sample size increases, the sample mean becomes less variable, which results in a smaller standard error and a more precise estimate of the population mean.
In the previous section, we used a sample to estimate a population mean and quantified the uncertainty around this estimate using a confidence interval. Sometimes, however, our research question is not simply “What is the population mean?” but rather “Is the population mean different from a particular value?” This is where hypothesis testing comes in.
The one-sample t-test can be used to test whether the mean of a population differs from a specific value.
For example, suppose we want to investigate whether the mean price in our population differs from 100.000 euro.
We start by defining two hypotheses:
We can perform this test in R using t.test():
t.test(apartment$price, mu=100000)
#>
#> One Sample t-test
#>
#> data: apartment$price
#> t = 1.7837, df = 116, p-value = 0.07709
#> alternative hypothesis: true mean is not equal to 1e+05
#> 95 percent confidence interval:
#> 99307.36 113239.65
#> sample estimates:
#> mean of x
#> 106273.5
The argument mu = 40 tells R that we want to test the
null hypothesis that the population mean is 100.000.
The output from t.test() contains several pieces of
information. The p-value is particularly important for hypothesis
testing. The p-value tells us how compatible our observed data are with
the null hypothesis. More specifically, it is the probability of
obtaining a result at least as extreme as the one observed, assuming
that the null hypothesis is true.
A common significance level is alpha=0.05 We compare the p-value with this significance level:
For the example above, the p-value is 0.07709. Since this is larger than 0.05, we do not reject the null hypothesis and conclude that the data do not provide evidence that the population mean price is different from 100000.
It is important to distinguish between “rejecting the null hypothesis” and “proving that the alternative hypothesis is true”. A hypothesis test provides evidence against the null hypothesis; it does not prove a hypothesis with certainty.
The test above is a two-sided test because our alternative hypothesis is “not equal to”.
If we have a directional research question (meaning “larger than”, or “smaller than”), we can perform a one-sided test. For example, if we specifically want to test whether the population mean is greater than 100000:
t.test(apartment$price, mu=100000, alternative = "greater")
#>
#> One Sample t-test
#>
#> data: apartment$price
#> t = 1.7837, df = 116, p-value = 0.03854
#> alternative hypothesis: true mean is greater than 1e+05
#> 95 percent confidence interval:
#> 100441.7 Inf
#> sample estimates:
#> mean of x
#> 106273.5
For a test of whether the mean is less than 100000:
t.test(apartment$price, mu=100000, alternative = "less")
#>
#> One Sample t-test
#>
#> data: apartment$price
#> t = 1.7837, df = 116, p-value = 0.9615
#> alternative hypothesis: true mean is less than 1e+05
#> 95 percent confidence interval:
#> -Inf 112105.3
#> sample estimates:
#> mean of x
#> 106273.5
The default in t.test() is:
alternative = "two.sided"
Therefore, if you do not specify alternative, R performs a two-sided test.
A one-sided test should only be used when a directional hypothesis was specified before looking at the results. We should not choose a one-sided test simply because it produces a smaller p-value.
When planning a study, we face an important question: How large should our sample be?
If our sample is too small, we may have little ability to detect an effect that is genuinely present in the population. This ability to detect an effect is called statistical power. Statistical power is the probability of rejecting the null hypothesis when the alternative hypothesis is actually true.
Power tells us how likely our study is to detect an effect of a particular size if that effect really exists. A commonly used target for statistical power is 80%.
Power depends on several factors, including:
Generally, power increases when the sample size increases. A larger sample provides more information and therefore makes it easier to distinguish a real effect from random sampling variation.
R provides the power.t.test() function to perform power
and sample size calculations for t-tests.
For example, suppose we want to know how much power we would have with a sample of 50 observations if we expect an effect size of 0.5.
power.t.test(
n = 50,
delta = 0.5,
sd = 1,
sig.level = 0.05,
type = "one.sample",
alternative = "two.sided"
)
#>
#> One-sample t test power calculation
#>
#> n = 50
#> delta = 0.5
#> sd = 1
#> sig.level = 0.05
#> power = 0.9338976
#> alternative = two.sided
Here:
The output includes the estimated power for these assumptions.
We can also use power.t.test() to determine the sample
size needed to achieve a desired level of power.
Instead of specifying n, we specify the desired power:
power.t.test(
power = 0.80,
delta = 0.5,
sd = 1,
sig.level = 0.05,
type = "one.sample",
alternative = "two.sided"
)
#>
#> One-sample t test power calculation
#>
#> n = 33.3672
#> delta = 0.5
#> sd = 1
#> sig.level = 0.05
#> power = 0.8
#> alternative = two.sided
R will calculate the required sample size to achieve approximately 80% power, assuming the other values are correct.
In this section, we will look at how to perform several of the statistical tests discussed in the course using R. The focus here is mainly on the practical implementation: how to specify the test in R, which arguments are important, and how to find the relevant information in the output.
The choice of statistical test depends on the type of variables, the number of groups, and whether the observations are independent or paired. The theoretical background and assumptions of these tests are discussed in the course slides. Here, we focus on translating these analyses into R.
When we want to compare the mean of a numerical variable between two groups, we can use a t-test. The appropriate version depends on whether the observations in the two groups are independent or paired.
An independent samples t-test is used when the two groups consist of different, independent observations.
For example, suppose we want to compare the mean price between two groups defined by area.
t.test(price~area, data=apartment)
#>
#> Welch Two Sample t-test
#>
#> data: price by area
#> t = -1.9675, df = 92.859, p-value = 0.05211
#> alternative hypothesis: true difference in means between group Non-residential and group Residential is not equal to 0
#> 95 percent confidence interval:
#> -27100.3514 125.9925
#> sample estimates:
#> mean in group Non-residential mean in group Residential
#> 97282.05 110769.23
The general structure is:
t.test(numerical_variable ~ grouping_variable, data = data)
Here:
- price is the numerical variable we want to compare.
- area is the categorical variable defining the two groups.
- data = apartment tells R that these variables can be found in the dataset called apartment.
The
~symbol can be read as “by”. Thus, price ~ area means that we want to compare price by area.
By default, t.test() performs Welch’s two-sample t-test,
which does not assume that the variances in the two groups are equal. If
we specifically want to perform the version that assumes equal
variances, we can use:
t.test(price~area, data=apartment, var.equal = TRUE)
#>
#> Two Sample t-test
#>
#> data: price by area
#> t = -1.8258, df = 115, p-value = 0.07048
#> alternative hypothesis: true difference in means between group Non-residential and group Residential is not equal to 0
#> 95 percent confidence interval:
#> -28119.51 1145.15
#> sample estimates:
#> mean in group Non-residential mean in group Residential
#> 97282.05 110769.23
The var.equal argument therefore specifies whether we
want to assume equal variances: var.equal = TRUE or
var.equal = FALSE. The default is FALSE.
The output contains the t-statistic, degrees of freedom, p-value, confidence interval, and the estimated mean in each group.
We can also specify whether we want a two-sided or one-sided test using the alternative argument:
# For one-sided test:
t.test(price~area, data=apartment, alternative="greater")
#>
#> Welch Two Sample t-test
#>
#> data: price by area
#> t = -1.9675, df = 92.859, p-value = 0.9739
#> alternative hypothesis: true difference in means between group Non-residential and group Residential is greater than 0
#> 95 percent confidence interval:
#> -24876.47 Inf
#> sample estimates:
#> mean in group Non-residential mean in group Residential
#> 97282.05 110769.23
t.test(price~area, data=apartment, alternative="less")
#>
#> Welch Two Sample t-test
#>
#> data: price by area
#> t = -1.9675, df = 92.859, p-value = 0.02606
#> alternative hypothesis: true difference in means between group Non-residential and group Residential is less than 0
#> 95 percent confidence interval:
#> -Inf -2097.892
#> sample estimates:
#> mean in group Non-residential mean in group Residential
#> 97282.05 110769.23
# For two-sided test:
t.test(price~area, data=apartment, alternative="two.sided")
#>
#> Welch Two Sample t-test
#>
#> data: price by area
#> t = -1.9675, df = 92.859, p-value = 0.05211
#> alternative hypothesis: true difference in means between group Non-residential and group Residential is not equal to 0
#> 95 percent confidence interval:
#> -27100.3514 125.9925
#> sample estimates:
#> mean in group Non-residential mean in group Residential
#> 97282.05 110769.23
The default is “two.sided”.
If the assumptions of the independent samples t-test are not
appropriate, we can use the Wilcoxon rank-sum test as a non-parametric
alternative. The Wilcoxon test works with the ranks of the observations
rather than directly comparing their means. It is therefore often used
when the assumptions required for the t-test are not met. In R, this is
performed using wilcox.test():
wilcox.test(price~area, data=apartment)
#>
#> Wilcoxon rank sum test with continuity correction
#>
#> data: price by area
#> W = 1240.5, p-value = 0.1054
#> alternative hypothesis: true location shift is not equal to 0
Sometimes the two groups are not independent. Instead, each observation in one group is directly related to an observation in the other group. For example, suppose we measure the blood pressure of the same individuals before and after an intervention. Because each person’s measurements are paired, we need a paired t-test.
For this purpose, we will use a different dataset: bloodpressure.txt: we measure the blood pressure of a group of nonpregnant, premenopausal women of age 16-49 who do not use oral contraceptives (OC). After this the women start to take OC and one 1 year later we measure the blood pressure again. The dataset has 4 variables:
bp <- read.table("bloodpressure.txt", header=T)
head(bp)
#> index SBPstart SBPend Difference
#> 1 1 115 128 13
#> 2 2 112 115 3
#> 3 3 107 106 -1
#> 4 4 119 128 9
#> 5 5 115 122 7
#> 6 6 138 145 7
We can perform a paired t-test using the t.test()
function, specifying the paired = TRUE option.
t.test(bp$SBPstart,bp$SBPend, paired=T)
#>
#> Paired t-test
#>
#> data: bp$SBPstart and bp$SBPend
#> t = -3.3247, df = 9, p-value = 0.008874
#> alternative hypothesis: true mean difference is not equal to 0
#> 95 percent confidence interval:
#> -8.066013 -1.533987
#> sample estimates:
#> mean difference
#> -4.8
The paired = TRUE option tells R that the two
measurements are paired and should not be treated as independent
observations.
The non-parametric alternative to the paired t-test is the Wilcoxon
signed-rank test. We use the same wilcox.test() function,
but specify: paired = TRUE
wilcox.test(bp$SBPstart,bp$SBPend, paired=T)
#> Warning in wilcox.test.default(bp$SBPstart, bp$SBPend, paired = T): cannot
#> compute exact p-value with ties
#>
#> Wilcoxon signed rank test with continuity correction
#>
#> data: bp$SBPstart and bp$SBPend
#> V = 3.5, p-value = 0.01646
#> alternative hypothesis: true location shift is not equal to 0
When we want to compare the means of more than two groups, we can use an analysis of variance (ANOVA).
In our apartment data, we have a numerical variable age and a
categorical variable priceclass with three levels. If we want to compare
the mean age of the buyers in the three different price classes, we can
perform a one-way ANOVA using aov():
model <- aov(age ~ factor(priceclass), data = apartment)
summary(model)
#> Df Sum Sq Mean Sq F value Pr(>F)
#> factor(priceclass) 2 1373 686.7 5.367 0.00592 **
#> Residuals 114 14588 128.0
#> ---
#> Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
The general structure is:
model <- aov(numerical_variable ~ factor(grouping_variable), data = data)
summary(model)
The first line creates the ANOVA model and stores it in an object called model.
The second line displays the results of the ANOVA.
The output contains an F-statistic and a p-value. The p-value tells us whether there is evidence that the group means are not all equal.
When comparing more than two groups with an ANOVA, a significant result tells us that at least one group differs from another, but it does not tell us which groups are different.
We can perform pairwise comparisons between the groups using
pairwise.t.test(). For example, if we want to compare age
between the different levels of priceclass:
pairwise.t.test(apartment$priceclass, apartment$priceclass)
#>
#> Pairwise comparisons using t tests with pooled SD
#>
#> data: apartment$priceclass and apartment$priceclass
#>
#> 1 2
#> 2 <2e-16 -
#> 3 <2e-16 <2e-16
#>
#> P value adjustment method: holm
The first argument is the numerical variable we want to compare, and the second argument is the grouping variable. R will perform a separate t-test for each possible pair of groups and provide the p-value for each comparison.
When we perform several statistical tests, the probability of
obtaining a significant result by chance increases. We therefore need to
adjust the p-values for multiple comparisons. We can specify the method
used for this adjustment with the p.adjust.method
argument.
pairwise.t.test(apartment$priceclass, apartment$priceclass,
p.adjust.method = "bonferroni")
#>
#> Pairwise comparisons using t tests with pooled SD
#>
#> data: apartment$priceclass and apartment$priceclass
#>
#> 1 2
#> 2 <2e-16 -
#> 3 <2e-16 <2e-16
#>
#> P value adjustment method: bonferroni
The Bonferroni correction is a simple and conservative way to adjust the p-values.
Another commonly used adjustment is Holm’s method:
pairwise.t.test(apartment$priceclass, apartment$priceclass,
p.adjust.method = "holm")
#>
#> Pairwise comparisons using t tests with pooled SD
#>
#> data: apartment$priceclass and apartment$priceclass
#>
#> 1 2
#> 2 <2e-16 -
#> 3 <2e-16 <2e-16
#>
#> P value adjustment method: holm
Holm’s method is generally less conservative than Bonferroni while still controlling the overall error rate.
The output is a table showing the adjusted p-value for each pairwise comparison. We can then see which specific groups differ from each other.
Another commonly used post-hoc test after a one-way ANOVA is Tukey’s HSD (Honestly Significant Difference). Tukey’s HSD compares all pairs of group means while adjusting for multiple comparisons.
model <- aov(age ~ factor(priceclass), data = apartment)
TukeyHSD(model)
#> Tukey multiple comparisons of means
#> 95% family-wise confidence level
#>
#> Fit: aov(formula = age ~ factor(priceclass), data = apartment)
#>
#> $`factor(priceclass)`
#> diff lwr upr p adj
#> 2-1 -6.938596 -12.657394 -1.219799 0.0130439
#> 3-1 -8.291667 -15.370646 -1.212687 0.0172859
#> 3-2 -1.353070 -7.889662 5.183522 0.8754681
The output contains a comparison for each pair of groups. For each comparison, you will see:
Important: Pairwise comparisons should generally be performed as follow-up analyses after an overall test such as ANOVA, rather than simply performing many separate t-tests without adjusting for multiple comparisons.
The non-parametric alternative to a one-way ANOVA is the Kruskal-Wallis test.
kruskal.test(age~priceclass, data=apartment)
#>
#> Kruskal-Wallis rank sum test
#>
#> data: age by priceclass
#> Kruskal-Wallis chi-squared = 10.602, df = 2, p-value = 0.004987
The Kruskal-Wallis test is based on the ranks of the observations rather than directly comparing the means.
As with ANOVA, a significant Kruskal-Wallis test tells us that there is evidence of a difference between the groups, but does not by itself tell us which particular groups differ.
Many statistical tests make certain assumptions about the data. Before performing a statistical test, it is therefore important to check whether these assumptions are reasonably met. The assumptions we need to check depend on the statistical test. Two common assumptions we encounter are:
In R, we can assess these assumptions using both graphical methods and statistical tests. Graphical methods are particularly useful because they allow us to see the actual distribution of the data, while statistical tests provide a formal test of a specific assumption.
One assumption of several parametric statistical tests is that the relevant data are approximately normally distributed. There are several ways to assess normality. Two common approaches are:
A QQ plot compares the distribution of our data with what we would
expect if the data came from a normal distribution. In base R, we can
create a QQ plot using qqnorm(). A reference line can be
added using qqline().
qqnorm(apartment$price)
qqline(apartment$price)
The interpretation is primarily visual. If the observations approximately follow the reference line, the data are reasonably consistent with a normal distribution. The points do not need to lie exactly on the line. In real data, some deviation from the line is expected. We are interested in whether there are substantial or systematic deviations from normality. A QQ plot is particularly useful for identifying features such as strong skewness or unusually extreme observations.
We can also perform a formal test of normality using the Shapiro-Wilk test:
shapiro.test(apartment$price)
#>
#> Shapiro-Wilk normality test
#>
#> data: apartment$price
#> W = 0.86152, p-value = 4.561e-09
The null hypothesis of the Shapiro-Wilk test is that the data are normally distributed. We can interpret the p-value in the usual way:
However, the Shapiro-Wilk test should not be used on its own. Its result is strongly influenced by the sample size. With a large sample, even small and practically unimportant deviations from normality can result in a significant p-value. With a small sample, substantial deviations may not be detected.
For this reason, it is useful to consider the QQ plot together with the Shapiro-Wilk test.
When comparing two groups with an independent samples t-test, we are interested in the distribution of the numerical variable within each group.
For example, if we compare price between areas, we can create separate QQ plots:
qqnorm(apartment$price[apartment$area=="Non-residential"], main="QQplot Price for Non-Residential area")
qqline(apartment$price[apartment$area=="Non-residential"])
qqnorm(apartment$price[apartment$area=="Residential"], main="QQplot Price for Residential area")
qqline(apartment$price[apartment$area=="Residential"])
We can also perform a Shapiro-Wilk test separately for each group:
shapiro.test(apartment$price[apartment$area=="Non-residential"])
#>
#> Shapiro-Wilk normality test
#>
#> data: apartment$price[apartment$area == "Non-residential"]
#> W = 0.85483, p-value = 0.0001416
shapiro.test(apartment$price[apartment$area=="Residential"])
#>
#> Shapiro-Wilk normality test
#>
#> data: apartment$price[apartment$area == "Residential"]
#> W = 0.86271, p-value = 5.789e-07
For a paired t-test, the relevant assumption concerns the differences between the paired observations, rather than the two variables separately. For example:
difference <- bp$SBPend-bp$SBPstart
qqnorm(difference)
qqline(difference)
shapiro.test(difference)
#>
#> Shapiro-Wilk normality test
#>
#> data: difference
#> W = 0.97703, p-value = 0.9473
When comparing the means of two or more independent groups, we may also need to consider whether the groups have similar variances. This is referred to as homogeneity of variances or equal variances.
We can visually investigate the spread of the groups using a boxplot:
boxplot(
price ~ area,
data = apartment,
main = "Price by Area",
xlab = "Area",
ylab = "Price"
)
The boxplots allow us to compare the spread of the observations between the groups.
We can also formally test the equality of variances using Levene’s
test. Levene’s test is available in the car package. In
order to be able to use the car package, it first needs to
be installed and then loaded into the R environment:
install.packages('car')
We can then perform the test:
library(car)
#> Loading required package: carData
leveneTest(price ~ area, data = apartment)
#> Levene's Test for Homogeneity of Variance (center = median)
#> Df F value Pr(>F)
#> group 1 2.38 0.1256
#> 115
The general syntax is:
leveneTest(numerical_variable ~ grouping_variable, data = data)
The null hypothesis of Levene’s test is that the variances are equal across the groups. As usual, we can use the p-value to assess the evidence against the null hypothesis:
Remember that a non-significant result does not prove that the variances are identical. It means that the test did not find sufficient evidence of a difference.
The tests above are mainly used when we have a numerical outcome and want to compare groups. When both variables are categorical, we can instead investigate whether there is an association between the variables.
For example, suppose we want to investigate whether the priceclass is associated with area. We can first create a contingency table:
table(apartment$area, apartment$priceclass)
#>
#> 1 2 3
#> Non-residential 13 21 5
#> Residential 23 36 19
This gives us the number of observations in each combination of the two variables.
The chi-square test of independence can be used to test whether two categorical variables are associated.
chisq.test(table(apartment$area, apartment$priceclass))
#>
#> Pearson's Chi-squared test
#>
#> data: table(apartment$area, apartment$priceclass)
#> X-squared = 2.1283, df = 2, p-value = 0.345
The table() function creates the contingency table,
which is then supplied to chisq.test().
The output contains the chi-square statistic, degrees of freedom, and p-value.
The chi-square test is based on comparing the observed frequencies with the frequencies we would expect if the two variables were independent. R allows us to inspect these expected frequencies:
test <- chisq.test(table(apartment$area, apartment$priceclass))
test$expected
#>
#> 1 2 3
#> Non-residential 12 19 8
#> Residential 24 38 16
This can be useful for checking whether the assumptions of the chi-square test are reasonable.
When the sample size is small, or when some of the expected frequencies in the contingency table are very small, the chi-square approximation may not be appropriate. In this situation, we can use Fisher’s exact test.
fisher.test(table(apartment$area, apartment$priceclass))
#>
#> Fisher's Exact Test for Count Data
#>
#> data: table(apartment$area, apartment$priceclass)
#> p-value = 0.3689
#> alternative hypothesis: two.sided
Fisher’s exact test calculates the exact probability based on the observed table rather than relying on the chi-square approximation.
Linear regression is used to describe the relationship between two numerical variables and to make predictions. We use one variable, the predictor or explanatory variable, to predict another variable, the outcome or response variable.
For example, we might want to investigate whether price is related to tax:
price = predictor variabletax = outcome variableThe first step is to visualise the relationship using a scatterplot. A scatterplot allows us to see whether there appears to be a linear relationship between two numerical variables.
plot(
apartment$price,
apartment$tax,
main = "Tax vs Price",
xlab = "Price",
ylab = "Tax",
pch = 19
)
Here, each point represents one observation.When looking at a scatterplot, consider:
In R, linear regression can be performed using the lm()
function.
model <- lm(tax ~ price, data = apartment)
The ~ symbol can be read as “is modelled by” or “as a
function of”.
To see the results of the regression model, use:
summary(model)
#>
#> Call:
#> lm(formula = tax ~ price, data = apartment)
#>
#> Residuals:
#> Min 1Q Median 3Q Max
#> -420.52 -79.35 3.27 77.71 460.82
#>
#> Coefficients:
#> Estimate Std. Error t value Pr(>|t|)
#> (Intercept) 2.229e+01 4.109e+01 0.542 0.589
#> price 7.122e-03 3.642e-04 19.556 <2e-16 ***
#> ---
#> Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
#>
#> Residual standard error: 149.2 on 115 degrees of freedom
#> Multiple R-squared: 0.7688, Adjusted R-squared: 0.7668
#> F-statistic: 382.5 on 1 and 115 DF, p-value: < 2.2e-16
The output contains several important pieces of information.
The Coefficients section of the output contains the
intercept and slope.
For example:
Coefficients:
Estimate Std. Error t value Pr(>|t|)
(Intercept) 2.229e+01 4.109e+01 0.542 0.589
price 7.122e-03 3.642e-04 19.556 <2e-16 ***
The regression equation can be written as:
predicted tax = intercept + slope × price
In this example:
predicted tax = 2.229e+01 + 7.122e-03 × height
The intercept is the predicted value of the outcome when the predictor is equal to zero. In this example, the intercept is 2.229e+01. This means that the model predicts a tax of 2.229e+01 when the price is zero.
The intercept is necessary for defining the regression line, but its practical interpretation is not always meaningful. For example, a price of zero may be outside the range of realistic observations.
The slope describes how much the predicted outcome changes for a one-unit increase in the predictor.
Here, the slope is 7.122e-03. This means that for every one-unit increase in price, the predicted tax increases by 7.122e-03 units, on average. A positive slope indicates a positive relationship, while a negative slope indicates a negative relationship.
The regression output also provides a hypothesis test for each coefficient. For the slope, the null and alternative hypotheses are:
The Pr(>|t|) column gives the p-value for this
test.
For example:
Coefficients:
Estimate Std. Error t value Pr(>|t|)
(Intercept) 2.229e+01 4.109e+01 0.542 0.589
price 7.122e-03 3.642e-04 19.556 <2e-16 ***
A small p-value provides evidence that the slope differs from zero. In this case, there is evidence of a linear association between price and age, because the p-value for the slope is <2e-16.
The regression output also contains the R-squared value.
For example:
Multiple R-squared: 0.7688
R-squared describes the proportion of variation in the outcome that is explained by the linear regression model. An R-squared of 0.77 means that approximately 77% of the variation in tax is explained by price in this linear model.
The R-squared is always a value between 0 and 1:
A high R-squared does not necessarily mean that the model is appropriate, and a low R-squared does not necessarily mean that a model is useless. The context and the purpose of the analysis are important.
The regression line can be added to the scatterplot using
abline().
plot(
apartment$price,
apartment$tax,
main = "Tax vs Price",
xlab = "Price",
ylab = "Tax",
pch = 19
)
abline(model)
Before interpreting a linear regression model, it is important to check whether its assumptions are reasonable.
Important assumptions include:
Regression assumptions are mainly assessed using the residuals.
A residual is the difference between the observed value and the value predicted by the regression model.
A residual plot can be created using:
plot(
model$fitted.values,
model$residuals,
xlab = "Fitted values",
ylab = "Residuals",
main = "Residual plot",
pch = 19
)
abline(h = 0)
The residuals should be scattered randomly around zero.
A reasonable residual plot should not show a clear pattern or systematic shape.
For example:
The residual plot therefore helps us assess both linearity and homoscedasticity.
The normality assumption concerns the residuals, rather than the original outcome variable.
A Q-Q plot can be used to assess normality:
qqnorm(model$residuals)
qqline(model$residuals)
If the points follow the reference line reasonably closely, the residuals are approximately normally distributed.
The Shapiro-Wilk test can also be used:
shapiro.test(model$residuals)
#>
#> Shapiro-Wilk normality test
#>
#> data: model$residuals
#> W = 0.97332, p-value = 0.01963
The null hypothesis of this test is that the residuals are normally distributed.
As with other normality tests, the result should not be interpreted in isolation. The Q-Q plot provides useful visual information, particularly because formal normality tests can be sensitive to sample size.
Once a regression model has been fitted, it can be used to predict the outcome for a new observation.
Suppose we want to predict the tax of an appartment with a price of 250.000 euro:
new_data <- data.frame(price = 250000)
predict(model, newdata = new_data)
#> 1
#> 1802.695
This gives the predicted mean outcome for an observation with a price of 250.000 euro.
When predicting an individual new observation, there is uncertainty around the prediction. A prediction interval gives a range of plausible values for the outcome of a new individual observation.
For example, to obtain a 95% prediction interval:
predict(
model,
newdata = new_data,
interval = "prediction",
level = 0.95
)
#> fit lwr upr
#> 1 1802.695 1488.29 2117.101
The output contains:
`fit` – predicted value `lwr` – lower limit of the prediction interval `upr` – upper limit of the prediction interval For example:
fit lwr upr
1 1802.695 1488.29 2117.101
This means that the predicted tax is 1802.695 with a 95% prediction interval from 1488.29 to 2117.101.