typeof() vs
class()[ ] vs [[ ]] rulefilter(),
select(), arrange()left_join() and
mutate(): deriving BMIcut()group_by() +
summarise()case_when()pivot_longer()This notebook is the hands-on companion to the 5-day R &
RStudio for Health Sciences course. It is
self-contained: the setup chunk below generates a small
synthetic clinical-trial dataset so every chunk in this notebook runs
top to bottom with no external files required. In a real course,
data-raw/ would instead hold your own de-identified export
(REDCap, Excel, SPSS, Stata, …) — everything you learn here about
importing and cleaning transfers directly.
How to use this notebook in RStudio: open the
.Rmd file, then run chunks one at a time with the green ▶
button (or Ctrl+Shift+Enter), or click
Preview to render the whole thing to
.nb.html. Text like this paragraph is Markdown; grey boxes
are R code chunks; output appears directly underneath each chunk.
# Packages used across the week. In a normal environment you would run
# install.packages() once for anything missing, then library() every
# session. This chunk loads only the packages that ship with a standard
# tidyverse + health-stats setup.
library(tidyverse) # dplyr, tidyr, ggplot2, readr, stringr, purrr, forcats
library(janitor) # clean_names() and friends
library(lubridate) # dates
library(survival) # Surv(), survfit(), coxph() -- ships with base R
library(broom) # tidy() -- turns model objects into data frames
library(haven) # SPSS / Stata import
library(readxl) # Excel import
library(flextable) # Word-ready tables
knitr::opts_chunk$set(fig.width = 7, fig.height = 4.5, dpi = 150)
dir.create("data-raw", showWarnings = FALSE)
dir.create("data-clean", showWarnings = FALSE)
dir.create("outputs", showWarnings = FALSE)
This chunk plays the role your own
00_generate_sample_data.R would play on a real project: it
creates three CSV files inside data-raw/, exactly as if
they had been exported from REDCap / Excel. Everything from Day 1 onward
reads these files back in, the same way you would read your own
data.
set.seed(2026)
n <- 220
sites <- c("Site A", "Site B", "Site C")
arms <- c("Control", "Intervention")
demographics <- data.frame(
patient_id = sprintf("P%03d", 1:n),
age = pmin(pmax(round(rnorm(n, 52, 14)), 18), 90),
sex = sample(c("F", "M"), n, replace = TRUE, prob = c(0.55, 0.45)),
site = sample(sites, n, replace = TRUE),
treatment_arm = sample(arms, n, replace = TRUE),
weight_kg = round(rnorm(n, 75, 14), 1),
height_m = round(rnorm(n, 1.68, 0.09), 2),
icd10 = sample(c("I10","I25","E11","E11.9","J45","J44","M54","N39"),
n, replace = TRUE),
satisfaction_raw = sample(
c("Very dissatisfied","Dissatisfied","Neutral","Satisfied","Very satisfied"),
n, replace = TRUE, prob = c(0.05,0.10,0.20,0.40,0.25)),
time_months = pmin(round(rexp(n, rate = 1/18), 1), 36),
stringsAsFactors = FALSE
)
demographics$event <- rbinom(n, 1, ifelse(demographics$treatment_arm == "Intervention", 0.28, 0.42))
bmi_tmp <- demographics$weight_kg / (demographics$height_m^2)
p_diab <- plogis(-6 + 0.04 * demographics$age + 0.09 * bmi_tmp)
demographics$diabetic <- rbinom(n, 1, p_diab) == 1
demographics$outcome <- round(rnorm(n, 5, 2), 1)
demographics$outcome[sample(seq_len(n), round(0.06 * n))] <- NA # a little missingness, on purpose
messy_idx <- sample(seq_len(n), round(0.08 * n)) # a little messiness, on purpose
demographics$site[messy_idx] <- paste0(" ", tolower(demographics$site[messy_idx]), " ")
write.csv(demographics, "data-raw/demographics.csv", row.names = FALSE)
labs1 <- data.frame(
patient_id = demographics$patient_id, visit = 1,
sbp = round(rnorm(n, 128 + 8 * demographics$diabetic, 12)),
dbp = round(rnorm(n, 80, 8)))
dup_ids <- sample(demographics$patient_id, round(0.15 * n)) # a few repeat visits, on purpose
labs2 <- data.frame(
patient_id = dup_ids, visit = 2,
sbp = round(rnorm(length(dup_ids), 124, 12)),
dbp = round(rnorm(length(dup_ids), 78, 8)))
write.csv(rbind(labs1, labs2), "data-raw/lab_results.csv", row.names = FALSE)
trial_raw <- data.frame(
`Patient ID` = demographics$patient_id, `Age` = demographics$age,
`Sex` = ifelse(demographics$sex == "F", "Female", "Male"),
`SBP (mmHg)` = labs1$sbp, `Diabetic` = demographics$diabetic,
check.names = FALSE)
trial_raw[3, "SBP (mmHg)"] <- 0 # an implausible value, on purpose
trial_raw[7, "Age"] <- 199 # an implausible value, on purpose
write.csv(trial_raw, "data-raw/clinical_trial_raw.csv", row.names = FALSE)
cat("data-raw/ ready:", nrow(demographics), "patients,",
nrow(labs1) + nrow(labs2), "lab visits.\n")
## data-raw/ ready: 220 patients, 253 lab visits.
Day 1 builds every core R skill from zero: the calculator, the four data structures you’ll use constantly (vectors, lists, matrices, data frames), factors, and the programming basics (if/else, loops, functions) that make everything later possible.
2 + 2 # addition
## [1] 4
10 - 3 # subtraction
## [1] 7
6 * 7 # multiplication
## [1] 42
100 / 8 # division
## [1] 12.5
2 ^ 5 # exponent -> 32
## [1] 32
17 %% 5 # modulo (remainder) -> 2
## [1] 2
17 %/% 5 # integer division -> 3
## [1] 3
round(3.14159, 2)
## [1] 3.14
print(), cat() and message()
all display text, but they are not interchangeable:
print() shows an R object (with quotes), cat()
prints raw formatted text, and message() writes to a
separate status stream.
print("Hello, World!")
## [1] "Hello, World!"
cat("Hello, World!\n")
## Hello, World!
name <- "Trainee"
paste("Hello,", name) # paste() glues pieces together, space-separated
## [1] "Hello, Trainee"
paste0("Hello, ", name, "!") # paste0() = no automatic space
## [1] "Hello, Trainee!"
2 + 3 * 4 # 14 -- multiplication before addition
## [1] 14
(2 + 3) * 4 # 20 -- parentheses override
## [1] 20
-2^2 # -4 -- unary minus applies AFTER ^ (a classic gotcha!)
## [1] -4
age <- 45
age >= 65 # is this patient a senior? -> FALSE
## [1] FALSE
5 == 5 # equal to (do NOT confuse with the single = of assignment)
## [1] TRUE
&/| are vectorized
(check every element — use inside filter());
&&/|| check a single
value and short-circuit (use inside if()).
TRUE & FALSE # AND (vectorized) -> FALSE
## [1] FALSE
TRUE | FALSE # OR (vectorized) -> TRUE
## [1] TRUE
!TRUE # NOT -> FALSE
## [1] FALSE
xor(TRUE, FALSE) # exclusive OR -> TRUE
## [1] TRUE
sbp_val <- 145
sbp_val > 140 && sbp_val < 180 # scalar AND, for if()
## [1] TRUE
typeof() vs class()typeof(2.5) # "double"
## [1] "double"
typeof(2L) # "integer"
## [1] "integer"
typeof("hello") # "character"
## [1] "character"
typeof(TRUE) # "logical"
## [1] "logical"
class(as.Date("2024-01-01")) # "Date" -- class() is usually what you want
## [1] "Date"
NA = missing, NULL = nothing at all,
NaN = undefined math, Inf = infinity. R will
silently convert types to the most flexible one in a mix —
always confirm with class() after importing real data.
c(1, "2", 3) # all become character
## [1] "1" "2" "3"
c(TRUE, 1, 2) # all become numeric (TRUE -> 1)
## [1] 1 1 2
raw_vals <- c("120", "135", "missing", "142")
as.numeric(raw_vals) # 120 135 NA 142 -- with a warning! Always check.
## Warning: NAs introduced by coercion
## [1] 120 135 NA 142
sbp <- c(128, 135, 142, 119, 151, 133)
sbp[3] # 142 -- third patient (R indexing starts at 1!)
## [1] 142
sbp[sbp > 130] # logical indexing -- only readings above 130
## [1] 135 142 151 133
mean(sbp); sd(sbp); length(sbp)
## [1] 134.6667
## [1] 11.0755
## [1] 6
sbp + 10 # vectorized arithmetic -- no loop needed
## [1] 138 145 152 129 161 143
[ ] vs [[ ]]
rule1:10 # integers 1 through 10
## [1] 1 2 3 4 5 6 7 8 9 10
seq(0, 100, by = 25) # 0 25 50 75 100
## [1] 0 25 50 75 100
patient1 <- list(id = "P001", age = 54, diagnoses = c("HTN", "T2DM"))
patient1$age # -> 54 (the value itself)
## [1] 54
patient1["age"] # -> a LIST containing age (still wrapped)
## $age
## [1] 54
patient1[["age"]] # -> 54 (same as $, unwrapped)
## [1] 54
lab_matrix <- matrix(c(120, 80, 135, 85, 142, 90), nrow = 3,
dimnames = list(c("P1","P2","P3"), c("SBP","DBP")))
lab_matrix["P2", "SBP"] # index like [row, col] -> 135
## [1] 80
patients <- data.frame(id = c("P01","P02","P03"), age = c(54,67,45),
sex = c("F","M","F"), sbp = c(128,142,119))
str(patients)
## 'data.frame': 3 obs. of 4 variables:
## $ id : chr "P01" "P02" "P03"
## $ age: num 54 67 45
## $ sex: chr "F" "M" "F"
## $ sbp: num 128 142 119
patients[patients$age > 50, ] # every patient over 50
severity <- factor(c("mild","severe","moderate","mild"),
levels = c("mild","moderate","severe"), ordered = TRUE)
levels(severity)
## [1] "mild" "moderate" "severe"
severity[1] < severity[2] # TRUE -- ordered comparison!
## [1] TRUE
table(severity)
## severity
## mild moderate severe
## 2 1 1
bmi_val <- 31
if (bmi_val < 18.5) {
"Underweight"
} else if (bmi_val < 25) {
"Normal weight"
} else if (bmi_val < 30) {
"Overweight"
} else {
"Obese"
}
## [1] "Obese"
for (i in 1:5) cat("Square of", i, "is", i^2, "\n")
## Square of 1 is 1
## Square of 2 is 4
## Square of 3 is 9
## Square of 4 is 16
## Square of 5 is 25
bmi_calc <- function(weight_kg, height_m) weight_kg / (height_m^2)
bmi_calc(70, 1.75) # 22.86
## [1] 22.85714
mean(c(120, 135, NA)) # NA -- contagious!
## [1] NA
mean(c(120, 135, NA), na.rm = TRUE) # 127.5
## [1] 127.5
trial_raw <- read_csv("data-raw/clinical_trial_raw.csv", show_col_types = FALSE)
trial_day1 <- trial_raw |> clean_names() # "Patient ID" -> patient_id, "SBP (mmHg)" -> sbp_mm_hg
names(trial_day1)
## [1] "patient_id" "age" "sex" "sbp_mm_hg" "diabetic"
dim(trial_day1)
## [1] 220 5
summary(trial_day1)
## patient_id age sex sbp_mm_hg
## Length:220 Min. : 18.00 Length:220 Min. : 0.0
## Class :character 1st Qu.: 42.75 Class :character 1st Qu.:121.0
## Mode :character Median : 52.00 Mode :character Median :128.0
## Mean : 53.15 Mean :128.0
## 3rd Qu.: 61.25 3rd Qu.:135.2
## Max. :199.00 Max. :158.0
## diabetic
## Mode :logical
## FALSE:174
## TRUE :46
##
##
##
saveRDS(trial_day1, "data-clean/trial_day1.rds")
Checkpoint: How many patients are in the dataset?
Which variable has implausible values (look closely at age
and sbp_mm_hg above — one row of each was deliberately
corrupted for this exercise)?
trial_day1 <- readRDS("data-clean/trial_day1.rds")
trial_day1 |>
filter(age >= 18) |>
select(age, sbp_mm_hg) |>
summary()
## age sbp_mm_hg
## Min. : 18.00 Min. : 0.0
## 1st Qu.: 42.75 1st Qu.:121.0
## Median : 52.00 Median :128.0
## Mean : 53.15 Mean :128.0
## 3rd Qu.: 61.25 3rd Qu.:135.2
## Max. :199.00 Max. :158.0
filter(), select(),
arrange()trial_day1 |> filter(age >= 18) |> nrow() # adults only
## [1] 220
trial_day1 |> select(patient_id, age, sex) |> head(3) # keep some columns
trial_day1 |> arrange(desc(age)) |> head(3) # oldest first
demographics <- read_csv("data-raw/demographics.csv", show_col_types = FALSE) |> clean_names()
labs <- read_csv("data-raw/lab_results.csv", show_col_types = FALSE) |> clean_names()
class(demographics$patient_id); class(labs$patient_id) # 1. types match?
## [1] "character"
## [1] "character"
labs |> count(patient_id) |> filter(n > 1) |> nrow() # 2. duplicate IDs?
## [1] 33
labs_latest <- labs |>
group_by(patient_id) |>
slice_max(visit, n = 1, with_ties = FALSE) |>
ungroup()
nrow(labs); nrow(labs_latest) # confirm duplicates are gone
## [1] 253
## [1] 220
left_join() and mutate(): deriving
BMIcombined <- demographics |> left_join(labs_latest, by = "patient_id")
nrow(demographics); nrow(combined) # 3. did the row count change unexpectedly?
## [1] 220
## [1] 220
combined <- combined |> mutate(bmi = weight_kg / (height_m^2))
combined |> select(patient_id, weight_kg, height_m, bmi) |> head(3)
cut()combined <- combined |>
mutate(age_group = cut(age, breaks = c(0, 18, 40, 65, Inf),
labels = c("<18","18-39","40-64","65+"), right = FALSE))
table(combined$age_group)
##
## <18 18-39 40-64 65+
## 0 39 140 41
group_by() + summarise()Notice the messy site values (" site a ",
"site b", …) are still splitting patients into extra,
spurious groups here — this is exactly the problem the string-cleaning
step fixes further down.
combined |>
group_by(site, sex) |>
summarise(n = n(), mean_age = mean(age, na.rm = TRUE),
mean_sbp = mean(sbp, na.rm = TRUE), .groups = "drop")
colSums(is.na(combined))
## patient_id age sex site
## 0 0 0 0
## treatment_arm weight_kg height_m icd10
## 0 0 0 0
## satisfaction_raw time_months event diabetic
## 0 0 0 0
## outcome visit sbp dbp
## 13 0 0 0
## bmi age_group
## 0 0
n_before <- nrow(combined)
combined <- combined |> drop_na(outcome)
n_before - nrow(combined) # patients excluded -- report this number in your methods
## [1] 13
case_when()combined <- combined |>
mutate(bp_category = case_when(
sbp < 120 ~ "Normal",
sbp >= 120 & sbp < 130 ~ "Elevated",
sbp >= 130 & sbp < 140 ~ "Stage 1",
sbp >= 140 ~ "Stage 2",
TRUE ~ NA_character_))
table(combined$bp_category, useNA = "ifany")
##
## Elevated Normal Stage 1 Stage 2
## 69 44 60 34
combined <- combined |>
mutate(diagnosis_group = case_when(
str_starts(icd10, "I") ~ "Cardiovascular",
str_starts(icd10, "E1") ~ "Diabetes",
str_starts(icd10, "J") ~ "Respiratory",
TRUE ~ "Other"))
table(combined$diagnosis_group)
##
## Cardiovascular Diabetes Other Respiratory
## 49 58 50 50
combined <- combined |>
mutate(satisfaction = factor(satisfaction_raw,
levels = c("Very dissatisfied","Dissatisfied","Neutral","Satisfied","Very satisfied"),
ordered = TRUE))
summary(combined$satisfaction)
## Very dissatisfied Dissatisfied Neutral Satisfied
## 8 25 46 69
## Very satisfied
## 59
messy_site <- c(" Site A ", "site b", "SITE C", "Site_A")
str_to_title(str_trim(messy_site)) # consistent Title Case
## [1] "Site A" "Site B" "Site C" "Site_a"
combined <- combined |> mutate(site = str_to_upper(str_trim(site)))
table(combined$site) # now exactly 3 clean groups
##
## SITE A SITE B SITE C
## 66 75 66
pivot_longer()wide_example <- data.frame(patient_id = c("P01","P02"),
sbp_visit1 = c(128, 142), sbp_visit2 = c(122, 138))
wide_example |>
pivot_longer(cols = starts_with("sbp_visit"), names_to = "visit", values_to = "sbp")
trial <- combined
saveRDS(trial, "data-clean/trial_day2.rds")
dim(trial)
## [1] 207 21
Checkpoint: How many patients were excluded for
missing outcome? Which diagnosis_group is most common?
Compare the group_by(site) output before and after the
string-cleaning step — how many groups collapsed into one?
ggplot() callEvery ggplot needs three ingredients: data, an
aes() mapping (which columns go to x/y/colour), and a
geom_*() layer for how to draw it.
trial <- readRDS("data-clean/trial_day2.rds")
ggplot(trial, aes(x = age, y = sbp)) +
geom_point() +
geom_smooth(method = "lm") +
labs(title = "Systolic BP by Age", x = "Age (years)", y = "SBP (mmHg)") +
theme_minimal()
my_theme <- theme_minimal(base_size = 12) +
theme(plot.title = element_text(face = "bold"),
panel.grid.minor = element_blank(),
legend.position = "bottom")
fig1 <- ggplot(trial, aes(x = sbp)) +
geom_histogram(binwidth = 5, fill = "#16A3A0", colour = "white") +
labs(title = "Distribution of Systolic Blood Pressure") + my_theme
fig1
fig2 <- ggplot(trial, aes(treatment_arm, sbp, fill = treatment_arm)) +
geom_boxplot() +
labs(title = "SBP by Treatment Arm", x = NULL) + my_theme +
theme(legend.position = "none")
fig2
ggplot(trial, aes(diagnosis_group)) +
geom_bar(fill = "#FF6B4A") + coord_flip() +
labs(title = "Patients by Diagnosis Group", x = NULL, y = "Count") + my_theme
ggplot(trial, aes(age, sbp, colour = sex)) +
geom_point(alpha = 0.6) +
facet_wrap(~ site) +
labs(title = "Age vs. SBP by Study Site") + my_theme
We build the curve with base survival, then hand it
to broom::tidy() so it plots with the same
ggplot() grammar as every other figure today — no extra
plotting package required.
fit <- survfit(Surv(time_months, event) ~ treatment_arm, data = trial)
km_df <- broom::tidy(fit) |>
mutate(strata = str_remove(strata, "treatment_arm="))
ggplot(km_df, aes(x = time, y = estimate, colour = strata, fill = strata)) +
geom_step(linewidth = 1) +
geom_ribbon(aes(ymin = conf.low, ymax = conf.high), alpha = 0.15, colour = NA) +
labs(x = "Months", y = "Survival probability", colour = "Arm", fill = "Arm",
title = "Kaplan-Meier Survival by Treatment Arm") +
my_theme
gtsummary::tbl_summary() is the standard tool for this
if it’s installed (install.packages("gtsummary")); this
chunk uses it when available and otherwise falls back to a plain dplyr
summary so the notebook always runs.
if (requireNamespace("gtsummary", quietly = TRUE)) {
library(gtsummary)
trial |>
select(age, sex, bmi, sbp, diagnosis_group, treatment_arm) |>
tbl_summary(by = treatment_arm) |>
add_p() |> add_overall() |> bold_labels()
} else {
trial |>
group_by(treatment_arm) |>
summarise(n = n(),
mean_age = round(mean(age, na.rm = TRUE), 1),
mean_bmi = round(mean(bmi, na.rm = TRUE), 1),
mean_sbp = round(mean(sbp, na.rm = TRUE), 1),
.groups = "drop")
}
##
## Attaching package: 'gtsummary'
## The following object is masked _by_ '.GlobalEnv':
##
## trial
## The following object is masked from 'package:flextable':
##
## continuous_summary
| Characteristic | Overall N = 2071 |
Control N = 1081 |
Intervention N = 991 |
p-value2 |
|---|---|---|---|---|
| age | 51 (42, 61) | 51 (42, 62) | 52 (42, 60) | 0.9 |
| sex | 0.8 | |||
| F | 113 (55%) | 58 (54%) | 55 (56%) | |
| M | 94 (45%) | 50 (46%) | 44 (44%) | |
| bmi | 25.9 (22.9, 30.2) | 26.6 (22.7, 30.9) | 25.8 (23.3, 29.8) | 0.5 |
| sbp | 128 (121, 137) | 128 (121, 135) | 128 (121, 138) | 0.6 |
| diagnosis_group | 0.3 | |||
| Cardiovascular | 49 (24%) | 31 (29%) | 18 (18%) | |
| Diabetes | 58 (28%) | 26 (24%) | 32 (32%) | |
| Other | 50 (24%) | 24 (22%) | 26 (26%) | |
| Respiratory | 50 (24%) | 27 (25%) | 23 (23%) | |
| 1 Median (Q1, Q3); n (%) | ||||
| 2 Wilcoxon rank sum test; Pearson’s Chi-squared test | ||||
ggsave("outputs/fig1_sbp_distribution.png", fig1, width = 7, height = 5, dpi = 300)
ggsave("outputs/fig2_sbp_by_arm.png", fig2, width = 7, height = 5, dpi = 300)
Checkpoint: Is SBP roughly normal or skewed (fig1)? Which arm has the higher median SBP (fig2)? Which baseline variable, if any, differs between arms in Table 1?
t.test(sbp ~ treatment_arm, data = trial) # 2 groups, continuous
##
## Welch Two Sample t-test
##
## data: sbp by treatment_arm
## t = -0.89284, df = 204.47, p-value = 0.373
## alternative hypothesis: true difference in means between group Control and group Intervention is not equal to 0
## 95 percent confidence interval:
## -4.563969 1.718851
## sample estimates:
## mean in group Control mean in group Intervention
## 127.5370 128.9596
chisq.test(table(trial$diagnosis_group, trial$sex)) # 2 categorical variables
##
## Pearson's Chi-squared test
##
## data: table(trial$diagnosis_group, trial$sex)
## X-squared = 2.1081, df = 3, p-value = 0.5503
fit_aov <- aov(sbp ~ site, data = trial) # 3+ group means
summary(fit_aov)
## Df Sum Sq Mean Sq F value Pr(>F)
## site 2 169 84.43 0.64 0.528
## Residuals 204 26902 131.87
wilcox.test(sbp ~ treatment_arm, data = trial) # instead of t.test, if skewed
##
## Wilcoxon rank sum test with continuity correction
##
## data: sbp by treatment_arm
## W = 5099, p-value = 0.5667
## alternative hypothesis: true location shift is not equal to 0
kruskal.test(sbp ~ site, data = trial) # instead of ANOVA, if skewed
##
## Kruskal-Wallis rank sum test
##
## data: sbp by site
## Kruskal-Wallis chi-squared = 2.1094, df = 2, p-value = 0.3483
fit_lm <- lm(sbp ~ age + bmi, data = trial)
summary(fit_lm)
##
## Call:
## lm(formula = sbp ~ age + bmi, data = trial)
##
## Residuals:
## Min 1Q Median 3Q Max
## -34.442 -7.033 -0.009 8.361 31.786
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) 124.95360 4.75851 26.259 <2e-16 ***
## age 0.09116 0.05812 1.569 0.118
## bmi -0.05553 0.13120 -0.423 0.673
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 11.45 on 204 degrees of freedom
## Multiple R-squared: 0.01291, Adjusted R-squared: 0.00323
## F-statistic: 1.334 on 2 and 204 DF, p-value: 0.2658
confint(fit_lm)
## 2.5 % 97.5 %
## (Intercept) 115.57142068 134.3357749
## age -0.02342837 0.2057386
## bmi -0.31422049 0.2031561
fit_glm <- glm(diabetic ~ age + bmi + sex, data = trial, family = binomial)
summary(fit_glm)
##
## Call:
## glm(formula = diabetic ~ age + bmi + sex, family = binomial,
## data = trial)
##
## Coefficients:
## Estimate Std. Error z value Pr(>|z|)
## (Intercept) -6.76685 1.25402 -5.396 6.81e-08 ***
## age 0.05980 0.01445 4.138 3.50e-05 ***
## bmi 0.07535 0.02889 2.608 0.00911 **
## sexM 0.05833 0.37317 0.156 0.87579
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## (Dispersion parameter for binomial family taken to be 1)
##
## Null deviance: 208.82 on 206 degrees of freedom
## Residual deviance: 182.99 on 203 degrees of freedom
## AIC: 190.99
##
## Number of Fisher Scoring iterations: 5
exp(cbind(OR = coef(fit_glm), confint(fit_glm))) # exponentiate: log-odds -> odds ratio
## Waiting for profiling to be done...
## OR 2.5 % 97.5 %
## (Intercept) 0.001151316 8.571301e-05 0.01201421
## age 1.061623305 1.032990e+00 1.09355291
## bmi 1.078266616 1.019795e+00 1.14299198
## sexM 1.060062446 5.070877e-01 2.20719644
survdiff(Surv(time_months, event) ~ treatment_arm, data = trial)
## Call:
## survdiff(formula = Surv(time_months, event) ~ treatment_arm,
## data = trial)
##
## N Observed Expected (O-E)^2/E (O-E)^2/V
## treatment_arm=Control 108 47 44.2 0.182 0.501
## treatment_arm=Intervention 99 25 27.8 0.289 0.501
##
## Chisq= 0.5 on 1 degrees of freedom, p= 0.5
cox_fit <- coxph(Surv(time_months, event) ~ treatment_arm + age + sex, data = trial)
summary(cox_fit)
## Call:
## coxph(formula = Surv(time_months, event) ~ treatment_arm + age +
## sex, data = trial)
##
## n= 207, number of events= 72
##
## coef exp(coef) se(coef) z Pr(>|z|)
## treatment_armIntervention -0.158934 0.853053 0.250891 -0.633 0.5264
## age -0.022691 0.977565 0.008816 -2.574 0.0101 *
## sexM 0.153167 1.165519 0.237340 0.645 0.5187
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## exp(coef) exp(-coef) lower .95 upper .95
## treatment_armIntervention 0.8531 1.172 0.5217 1.3949
## age 0.9776 1.023 0.9608 0.9946
## sexM 1.1655 0.858 0.7320 1.8559
##
## Concordance= 0.607 (se = 0.042 )
## Likelihood ratio test= 7.79 on 3 df, p=0.05
## Wald test = 7.76 on 3 df, p=0.05
## Score (logrank) test = 7.79 on 3 df, p=0.05
cox.zph(cox_fit) # check the proportional-hazards assumption
## chisq df p
## treatment_arm 0.0152 1 0.90
## age 1.3124 1 0.25
## sex 0.2358 1 0.63
## GLOBAL 1.4144 3 0.70
Checkpoint: Was the t-test significant? Is BMI a
significant predictor of SBP in fit_lm? Which variable has
the largest odds ratio for diabetes? Did cox.zph() suggest
the proportional-hazards assumption held?
Inside a Quarto/R Markdown document (like this one!), writing a piece of inline code such as the line below directly into a sentence always reflects the current data — re-render and the numbers update themselves, with no manual copy-pasting of statistics:
The mean age was 52.1 years.
round(mean(trial$age, na.rm = TRUE), 1)
## [1] 52.1
round(sd(trial$age, na.rm = TRUE), 1)
## [1] 13.7
The mean participant age in this run was 52.1 years (SD = 13.7) — that sentence itself was generated the same way.
tbl_base <- trial |>
group_by(treatment_arm) |>
summarise(n = n(), mean_sbp = round(mean(sbp, na.rm = TRUE), 1), .groups = "drop")
ft <- flextable(tbl_base)
ft
treatment_arm | n | mean_sbp |
|---|---|---|
Control | 108 | 127.5 |
Intervention | 99 | 129.0 |
save_as_docx(ft, path = "outputs/table1.docx")
Run in the RStudio Terminal tab, not the R console:
git init
git add .
git commit -m "Add BMI and age-group derived variables"
git remote add origin <your-github-repo-url>
git push -u origin main
.qmd/.Rmd)generate-data chunk for your own
read_csv() / read_excel() importrmarkdown::render())
to produce a shareable .nb.html or .docxcat("Notebook complete -- Day 1 through Day 5.\n")
## Notebook complete -- Day 1 through Day 5.