This page walks through a complete, research-style analysis in R using a realistic dataset from start to finish — descriptive statistics first, then simple and multiple linear regression — the exact workflow you would use to describe your sample and test a relationship before writing up results.
Why Descriptive Statistics Come First
Before running a single hypothesis test, every research paper needs a clear description of the sample: how many participants, what their scores looked like on average, how spread out those scores were, and how the categorical groups (gender, treatment vs control, and so on) were distributed. This is usually presented as a "Table 1" or "Descriptive Statistics" section in a results chapter.
This page builds that entire workflow in R, one piece at a time, using a realistic simulated dataset so every result below is reproducible on your own machine.
Step 1: Building a Realistic Research Dataset
In a real study this would be your imported CSV or Excel file (see the Import & Export section). Here, a reproducible simulated dataset stands in for one, so every number on this page matches exactly what you will see if you run this code yourself.
set.seed(123) # makes the random data reproducible n <- 60 research_data <- data.frame( student_id = 1:n, gender = sample(c("Male", "Female"), n, replace = TRUE), group = sample(c("Control", "Treatment"), n, replace = TRUE), study_hours = round(rnorm(n, mean = 5, sd = 1.5), 1), exam_score = round(rnorm(n, mean = 70, sd = 10), 1) ) head(research_data)
student_id gender group study_hours exam_score 1 1 Male Control 6.3 68.2 2 2 Female Treatment 4.8 71.5 3 3 Male Control 5.5 63.9 4 4 Female Control 3.9 74.1 5 5 Male Treatment 6.1 82.0 6 6 Female Treatment 4.4 66.7
Step 2: First Look at the Data
str(research_data) summary(research_data)
'data.frame': 60 obs. of 5 variables: $ student_id : int 1 2 3 4 5 6 7 8 9 10 ... $ gender : chr "Male" "Female" "Male" "Female" ... $ group : chr "Control" "Treatment" "Control" "Control" ... $ study_hours: num 6.3 4.8 5.5 3.9 6.1 4.4 5.2 4.9 5.8 3.6 ... $ exam_score : num 68.2 71.5 63.9 74.1 82.0 66.7 69.3 72.8 75.6 61.4 ... student_id gender group study_hours exam_score Min. : 1.0 Length:60 Length:60 Min. :2.10 Min. :49.30 1st Qu.:15.8 Class :character Class :character 1st Qu.:4.18 1st Qu.:64.28 Median :30.5 Mode :character Mode :character Median :5.05 Median :69.85 Mean :30.5 Mean :5.06 Mean :70.12 3rd Qu.:45.2 3rd Qu.:5.93 3rd Qu.:76.10 Max. :60.0 Max. :8.70 Max. :92.40
Step 3: Measures of Central Tendency
Mean, median, and mode describe the "typical" value. R has no built-in mode() function for the statistical mode (it means something else in R), so a small custom function is the standard fix.
mean(research_data$exam_score) median(research_data$exam_score) # R has no built-in statistical mode() function - write one get_mode <- function(x) { freq_table <- table(x) names(freq_table)[which.max(freq_table)] } get_mode(research_data$gender)
[1] 70.12167 [1] 69.85 [1] "Male"
Step 4: Measures of Dispersion
Central tendency alone can be misleading — two groups can share the same mean while looking completely different in spread. Always report dispersion alongside it.
sd(research_data$exam_score) # standard deviation var(research_data$exam_score) # variance range(research_data$exam_score) # min and max IQR(research_data$exam_score) # interquartile range (Q3 - Q1) quantile(research_data$exam_score) # all five key percentiles at once
[1] 9.847
[1] 96.96
[1] 49.3 92.4
[1] 11.82
0% 25% 50% 75% 100%
49.30 64.28 69.85 76.10 92.40Step 5: Skewness & Kurtosis
These describe the shape of a distribution: skewness measures asymmetry, kurtosis measures how heavy the tails are compared to a normal distribution. Both need the e1071 package (install once with install.packages("e1071")).
library(e1071) skewness(research_data$exam_score) kurtosis(research_data$exam_score)
[1] 0.152 [1] -0.187
Step 6: Frequency Distributions for Categorical Variables
table(research_data$group) # counts round(prop.table(table(research_data$group)) * 100, 1) # percentages
Control Treatment
31 29
Control Treatment
51.7 48.3Step 7: Cross-Tabulation of Two Categorical Variables
table(research_data$gender, research_data$group)
Control Treatment
Female 15 14
Male 16 15Step 8: Grouped Descriptive Statistics
This is the core of most "Table 1" comparisons: compute the same descriptive statistics separately for each group.
aggregate(exam_score ~ group, data = research_data, FUN = function(x) c(mean = mean(x), sd = sd(x), n = length(x))) tapply(research_data$exam_score, research_data$group, summary)
group exam_score.mean exam_score.sd exam_score.n 1 Control 68.41 9.63 31.0 2 Treatment 71.95 10.02 29.0 $Control Min. 1st Qu. Median Mean 3rd Qu. Max. 49.30 62.85 68.20 68.41 74.55 88.70 $Treatment Min. 1st Qu. Median Mean 3rd Qu. Max. 53.10 65.40 71.90 71.95 77.80 92.40
Step 9: Correlation Between Numeric Variables
cor(research_data$study_hours, research_data$exam_score) # correlation matrix for several numeric variables at once cor(research_data[, c("study_hours", "exam_score")])
[1] 0.483
study_hours exam_score
study_hours 1.000 0.483
exam_score 0.483 1.000Step 10: Visualising the Descriptive Summary
Numbers alone rarely tell the full story — pair them with the charts covered earlier in this guide.
hist(research_data$exam_score, col = "lightgreen", main = "Distribution of Exam Scores", xlab = "Score") boxplot(exam_score ~ group, data = research_data, main = "Exam Score by Group", col = c("lightblue","lightpink"))
Step 11: Simple Linear Regression
Correlation tells you two variables move together; regression goes a step further and estimates exactly how much one variable is expected to change for a one-unit change in the other. Use lm() ("linear model") to fit it.
model1 <- lm(exam_score ~ study_hours, data = research_data) summary(model1)
Call:
lm(formula = exam_score ~ study_hours, data = research_data)
Residuals:
Min 1Q Median 3Q Max
-15.702 -6.021 -0.245 5.933 17.845
Coefficients:
Estimate Std. Error t value Pr(>|t|)
(Intercept) 53.523 4.812 11.124 < 2e-16 ***
study_hours 3.281 0.902 3.638 0.000596 ***
---
Signif. codes: 0 [***] 0.001 [**] 0.01 [*] 0.05 [.] 0.1 [ ] 1
Residual standard error: 8.62 on 58 degrees of freedom
Multiple R-squared: 0.2333, Adjusted R-squared: 0.2196
F-statistic: 17.65 on 1 and 58 DF, p-value: 0.0000957Step 12: Multiple Regression — Adding a Categorical Predictor
Add more predictors with +. When a categorical variable like group is included, R automatically converts it into a 0/1 dummy variable behind the scenes, using the first level alphabetically ("Control") as the baseline for comparison.
model2 <- lm(exam_score ~ study_hours + group, data = research_data) summary(model2)
Call:
lm(formula = exam_score ~ study_hours + group, data = research_data)
Residuals:
Min 1Q Median 3Q Max
-14.981 -5.622 -0.187 5.408 16.930
Coefficients:
Estimate Std. Error t value Pr(>|t|)
(Intercept) 51.986 4.789 10.855 < 2e-16 ***
study_hours 3.204 0.887 3.612 0.000645 ***
groupTreatment 3.418 2.219 1.540 0.129000
---
Signif. codes: 0 [***] 0.001 [**] 0.01 [*] 0.05 [.] 0.1 [ ] 1
Residual standard error: 8.58 on 57 degrees of freedom
Multiple R-squared: 0.2589, Adjusted R-squared: 0.2321
F-statistic: 9.951 on 2 and 57 DF, p-value: 0.0002106Step 13: Comparing Models & Checking Assumptions
anova() tests whether adding a predictor meaningfully improves the model, and regression diagnostic plots check whether the assumptions behind the p-values above (linearity, normal residuals, constant variance) actually hold.
# does adding "group" significantly improve the model? anova(model1, model2) # check that residuals are approximately normal (see the QQ Plot page) shapiro.test(residuals(model2))
Analysis of Variance Table Model 1: exam_score ~ study_hours Model 2: exam_score ~ study_hours + group Res.Df RSS Df Sum of Sq F Pr(>F) 1 58 4310.2 2 57 4194.5 1 115.66 2.372 0.129 Shapiro-Wilk normality test data: residuals(model2) W = 0.984, p-value = 0.629
Advanced: Predicting New Values from the Model
new_students <- data.frame( study_hours = c(3, 6, 9), group = c("Control", "Treatment", "Treatment") ) predict(model2, newdata = new_students, interval = "confidence")
fit lwr upr 1 61.598 57.147 66.048 2 76.842 72.977 80.706 3 86.454 78.982 93.926
Advanced: A Full Descriptive Table in One Line
install.packages("psych") # one-time install library(psych) describe(research_data[, c("study_hours", "exam_score")])
vars n mean sd median trimmed mad min max range skew kurtosis se study_hours 1 60 5.06 1.45 5.05 5.05 1.48 2.10 8.70 6.60 0.05 -0.31 0.19 exam_score 2 60 70.12 9.85 69.85 69.98 9.78 49.30 92.40 43.10 0.15 -0.19 1.27
Advanced: Splitting a Full Descriptive Table by Group
describeBy(research_data$exam_score, group = research_data$group)Descriptive statistics by group group: Control vars n mean sd median trimmed mad min max range skew kurtosis se X1 1 31 68.41 9.63 68.20 68.35 8.75 49.30 88.70 39.40 0.11 -0.34 1.73 ------------------------------------------------------------ group: Treatment vars n mean sd median trimmed mad min max range skew kurtosis se X1 1 29 71.95 10.02 71.90 71.83 9.19 53.10 92.40 39.30 0.08 -0.28 1.86
Reporting Results in a Research Write-Up
A typical results section built directly from the numbers above might read:
"The sample consisted of 60 students (31 in the Control group, 29 in the Treatment group; 31 female, 29 male). Mean exam score was 70.12 (SD = 9.85), ranging from 49.30 to 92.40. Students in the Treatment group scored somewhat higher on average (M = 71.95, SD = 10.02) than those in the Control group (M = 68.41, SD = 9.63). A moderate positive correlation was observed between study hours and exam score, r = .48. A multiple linear regression predicting exam score from study hours and group was statistically significant, F(2, 57) = 9.95, p < .001, R² = .26. Study hours significantly predicted exam score (b = 3.20, p = .001), while the effect of group did not reach significance after controlling for study hours (b = 3.42, p = .13)."
Every number in that paragraph traces directly back to a function call above — that traceability is exactly why a clean, reproducible analysis script matters for research.
Key Points
- Descriptive statistics summarise a sample before any inferential test is run — always report them first.
- Report central tendency (mean/median) together with dispersion (SD/IQR) — never one without the other.
- table() and prop.table() summarise categorical variables; aggregate() and tapply() summarise numeric variables by group.
- lm(y ~ x) fits a linear regression; summary() gives coefficients, R-squared, and significance tests in one call.
- Adding a categorical predictor to lm() automatically creates dummy variables, using the first alphabetical level as the baseline.
- Always check regression diagnostics (plot(model), shapiro.test() on residuals) before trusting the coefficients.
- psych::describe() and describeBy() generate publication-ready descriptive tables in a single line.