From c1fd988d705e1db2d41873f7708f8aadcab2312c Mon Sep 17 00:00:00 2001 From: Peter Slijkhuis Date: Mon, 19 Oct 2026 09:16:49 +0000 Subject: [PATCH 1/8] Align the R teaching with the BMS Intro R reference guide Add Lesson 2-4 on missing values, recoding and joining (na_if, is.na, na.omit, case_when, ordered factors, ifelse dummies, rename, left_join, reading Excel and SPSS files with labels), with exercise m2-4-a. Add what the guide expects where the course was silent: data types and the global environment, script style and ?? help, the BMS package list, the pipe shortcut, tabyl() crosstabs with janitor, the goodness-of-fit test, pt/qt/pchisq/qchisq, Levene's and Welch's tests, Shapiro-Wilk, Breusch-Pagan and VIF, KMO and Bartlett before a factor analysis, and the CTT, Lambda4 and mirt pointers for psychometrics. Frame the site for BMS bachelor students and point to the BMS Methodology Shop for help with students' own research. --- README.md | 8 +- src/App.css | 3 + src/content/content.test.ts | 4 +- src/content/exercises/module-02.ts | 63 ++++++ src/content/guideIndex.ts | 13 +- .../lessons/01-1-objects-and-scripts.mdx | 15 +- .../lessons/01-2-functions-and-help.mdx | 8 +- .../lessons/01-3-packages-and-libraries.mdx | 20 ++ src/content/lessons/02-1-reading-data.mdx | 26 +++ src/content/lessons/02-2-pipe-and-verbs.mdx | 4 + .../lessons/02-4-missing-and-recoding.mdx | 200 ++++++++++++++++++ .../lessons/03-4-scales-and-reliability.mdx | 5 + .../lessons/08-2-p-values-and-alpha.mdx | 20 ++ src/content/lessons/09-2-tables-of-counts.mdx | 26 +++ src/content/lessons/09-3-chi-square-test.mdx | 20 ++ .../lessons/11-4-checking-the-model.mdx | 27 +++ src/content/lessons/12-1-two-groups.mdx | 17 ++ src/content/lessons/17-2-factor-analysis.mdx | 18 +- src/content/manifest.ts | 9 + src/pages/Home.tsx | 10 +- src/r/packages.ts | 4 +- src/r/session.itest.ts | 22 ++ src/r/session.ts | 2 +- 23 files changed, 524 insertions(+), 20 deletions(-) create mode 100644 src/content/lessons/02-4-missing-and-recoding.mdx diff --git a/README.md b/README.md index ffef3f1..0dcc0b5 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- StatLab: learn statistics by doing it in R, right in your browser. 18 modules, 64 lessons, 89 exercises, 0 installs. + StatLab: learn statistics by doing it in R, right in your browser. 18 modules, 65 lessons, 90 exercises, 0 installs.

@@ -24,7 +24,7 @@

- Built for psychology and business students at the University of Twente.
+ Built for BMS bachelor students at the University of Twente, following the R taught in the Research Methods and Statistics courses.
Made by dr. P.J.H. Slijkhuis and dr. V.d.C. Resendez Gomez, based on materials provided by dr. S.J. Watson. Theory, terminology and topic order follow Analysing Data Using Linear Models by S.M. van den Berg (5th ed., University of Twente, 2021; Creative Commons BY-NC-SA licence); the lessons are written independently.

@@ -49,7 +49,7 @@

โœ… Answers checked by R

- 89 exercises are marked by R itself. A wrong answer gets feedback on the specific mistake, not just a red cross. + 90 exercises are marked by R itself. A wrong answer gets feedback on the specific mistake, not just a red cross.

๐Ÿง‘โ€๐Ÿ”ฌ An avatar that coaches

@@ -165,7 +165,7 @@ live in the browser, and export to a file that imports on any other computer. ## The course -**18 modules ยท 64 lessons ยท 89 checked exercises ยท 6 interactive simulations.** +**18 modules ยท 65 lessons ยท 90 checked exercises ยท 6 interactive simulations.** From "what is a file path?" to mixed models, logistic regression, mediation and Bayes. Statistics is taught the way the course team's own R workshops teach it: in tidyverse style and through the linear model. `lm`, `lmer` and `glm` do the diff --git a/src/App.css b/src/App.css index 9cb7c4c..02af514 100644 --- a/src/App.css +++ b/src/App.css @@ -316,6 +316,9 @@ button.sidebar-lesson { width: 100%; border: 0; background: none; font: inherit; .lesson-link.complete a::before { border-color: var(--success); background: var(--success); } .lesson-link a:hover { color: var(--brand); } +.home-help { margin-top: 3rem; padding: 1.4rem 1.5rem; border-radius: var(--radius); background: var(--surface); border: 1px solid var(--line); } +.home-help h2 { margin-top: 0; font-size: 1.2rem; } +.home-help p { color: var(--ink-2); margin-bottom: 0; } .home-save { margin-top: 3rem; padding: 1.4rem 1.5rem; border-radius: var(--radius); background: var(--surface); border: 1px dashed var(--line-strong); } .home-save h2 { margin-top: 0; font-size: 1.2rem; } .home-save p { color: var(--ink-2); } diff --git a/src/content/content.test.ts b/src/content/content.test.ts index 4b0a305..bb3835f 100644 --- a/src/content/content.test.ts +++ b/src/content/content.test.ts @@ -313,9 +313,9 @@ describe('lesson content', () => { } }); - test('Module 2 defines exactly its five exercises, in order', () => { + test('Module 2 defines exactly its six exercises, in order', () => { expect(module02.map((exercise) => exercise.id)).toEqual([ - 'm2-1-a', 'm2-1-b', 'm2-2-a', 'm2-2-b', 'm2-3-a', + 'm2-1-a', 'm2-1-b', 'm2-2-a', 'm2-2-b', 'm2-3-a', 'm2-4-a', ]); }); diff --git a/src/content/exercises/module-02.ts b/src/content/exercises/module-02.ts index d16ffa1..769ad1a 100644 --- a/src/content/exercises/module-02.ts +++ b/src/content/exercises/module-02.ts @@ -291,4 +291,67 @@ export const module02: ExerciseDef[] = [ 'names_to is the column that will hold engagement_t1 and engagement_t2 as labels; values_to holds the scores.', ], }, + { + id: 'm2-4-a', + prompt: + 'Recode workload into workload_level: below 4 is "low", 4 up to but not including 7 is "medium", and 7 or more is "high". Store it in employees as an ordered factor with the levels in that order, and store table(employees$workload_level) in level_counts.', + starterCode: + 'library(dplyr)\nemployees <- read.csv("data/workplace.csv", stringsAsFactors = TRUE)\n\n# case_when() for the bands, then factor() for the order.\nemployees <- employees %>%\n mutate(workload_level = case_when(\n \n ))\n\nlevel_counts <- ', + solution: + 'library(dplyr)\nemployees <- read.csv("data/workplace.csv", stringsAsFactors = TRUE)\nemployees <- employees %>%\n mutate(workload_level = case_when(\n workload < 4 ~ "low",\n workload >= 4 & workload < 7 ~ "medium",\n workload >= 7 ~ "high"\n )) %>%\n mutate(workload_level = factor(workload_level, levels = c("low", "medium", "high"), ordered = TRUE))\nlevel_counts <- table(employees$workload_level)', + wrongAnswers: [ + // Plain text: the counts are right but the order is alphabetical (high, low, medium). + 'library(dplyr)\nemployees <- read.csv("data/workplace.csv", stringsAsFactors = TRUE)\nemployees <- employees %>%\n mutate(workload_level = case_when(\n workload < 4 ~ "low",\n workload >= 4 & workload < 7 ~ "medium",\n workload >= 7 ~ "high"\n ))\nlevel_counts <- table(employees$workload_level)', + // <= where < was asked: a workload of exactly 4 lands in "low". + 'library(dplyr)\nemployees <- read.csv("data/workplace.csv", stringsAsFactors = TRUE)\nemployees <- employees %>%\n mutate(workload_level = case_when(\n workload <= 4 ~ "low",\n workload > 4 & workload <= 7 ~ "medium",\n workload > 7 ~ "high"\n )) %>%\n mutate(workload_level = factor(workload_level, levels = c("low", "medium", "high"), ordered = TRUE))\nlevel_counts <- table(employees$workload_level)', + // The order is set, but not marked as meaningful. + 'library(dplyr)\nemployees <- read.csv("data/workplace.csv", stringsAsFactors = TRUE)\nemployees <- employees %>%\n mutate(workload_level = case_when(\n workload < 4 ~ "low",\n workload >= 4 & workload < 7 ~ "medium",\n workload >= 7 ~ "high"\n )) %>%\n mutate(workload_level = factor(workload_level, levels = c("low", "medium", "high")))\nlevel_counts <- table(employees$workload_level)', + ], + alternateSolutions: [ + // The earlier conditions are already false, so each line only needs its upper bound; TRUE catches the rest. + 'library(dplyr)\nemployees <- read.csv("data/workplace.csv", stringsAsFactors = TRUE)\nemployees <- employees %>%\n mutate(workload_level = case_when(\n workload < 4 ~ "low",\n workload < 7 ~ "medium",\n TRUE ~ "high"\n ))\nemployees$workload_level <- factor(employees$workload_level, levels = c("low", "medium", "high"), ordered = TRUE)\nlevel_counts <- table(employees$workload_level)', + // cut() with right = FALSE makes each band include its lower bound. + 'employees <- read.csv("data/workplace.csv", stringsAsFactors = TRUE)\nemployees$workload_level <- cut(employees$workload, breaks = c(-Inf, 4, 7, Inf), labels = c("low", "medium", "high"), right = FALSE, ordered_result = TRUE)\nlevel_counts <- table(employees$workload_level)', + ], + check: ` + if (!has_answer("employees") || !has_answer("level_counts")) { + list(pass = FALSE, message = "I need employees (with a workload_level column) and level_counts.") + } else { + employees <- answer("employees") + level_counts <- answer("level_counts") + d <- read.csv("data/workplace.csv", stringsAsFactors = TRUE) + expected <- ifelse(d$workload < 4, "low", ifelse(d$workload < 7, "medium", "high")) + wrong_edges <- ifelse(d$workload <= 4, "low", ifelse(d$workload <= 7, "medium", "high")) + if (!is.data.frame(employees) || !("workload_level" %in% names(employees))) { + list(pass = FALSE, message = "employees has no workload_level column. Assign the result of mutate() back to employees.") + } else if (nrow(employees) != nrow(d)) { + list(pass = FALSE, message = paste0("employees has ", nrow(employees), " rows, but the file has ", nrow(d), ". Recoding adds a column; it should not drop anyone.")) + } else if (any(is.na(employees$workload_level))) { + list(pass = FALSE, message = paste0(sum(is.na(employees$workload_level)), " employees got NA. case_when() gives NA to rows that match no condition, so check that the three bands cover every workload, or that the labels in factor() are spelled exactly as in case_when().")) + } else if (!setequal(unique(as.character(employees$workload_level)), c("low", "medium", "high"))) { + list(pass = FALSE, message = "workload_level should hold exactly three values: low, medium and high.") + } else if (!all(as.character(employees$workload_level) == expected) && all(as.character(employees$workload_level) == wrong_edges)) { + list(pass = FALSE, message = "The edges are off: a workload of exactly 4 should be medium and exactly 7 should be high. Use < for the upper bound of each band and >= for the lower bound.") + } else if (!all(as.character(employees$workload_level) == expected)) { + list(pass = FALSE, message = "Some employees are in the wrong band. Below 4 is low, 4 up to but not including 7 is medium, 7 or more is high.") + } else if (!is.factor(employees$workload_level)) { + list(pass = FALSE, message = "The bands are right, but workload_level is plain text, so tables list it alphabetically: high, low, medium. Turn it into a factor with levels = c(\\"low\\", \\"medium\\", \\"high\\").") + } else if (!identical(levels(employees$workload_level), c("low", "medium", "high"))) { + list(pass = FALSE, message = paste0("The levels are in the order ", paste(levels(employees$workload_level), collapse = ", "), ". Give factor() levels = c(\\"low\\", \\"medium\\", \\"high\\").")) + } else if (!is.ordered(employees$workload_level)) { + list(pass = FALSE, message = "The order is right, but R does not know it means something. Add ordered = TRUE to factor(), so the levels print as low < medium < high.") + } else if (length(level_counts) != 3L || !identical(names(level_counts), c("low", "medium", "high")) || !isTRUE(all.equal(as.vector(level_counts), as.vector(table(factor(expected, levels = c("low", "medium", "high")))), check.attributes = FALSE))) { + list(pass = FALSE, message = "level_counts should be table(employees$workload_level), made after the recoding.") + } else { + counts <- table(factor(expected, levels = c("low", "medium", "high"))) + list(pass = TRUE, message = paste0("Correct: ", counts[["low"]], " low, ", counts[["medium"]], " medium and ", counts[["high"]], " high, in that order. An ordered factor is how R stores an ordinal variable.")) + } + } + `, + hints: [ + 'Each line of case_when() is a condition, a tilde and a label: workload < 4 ~ "low".', + 'The middle band needs two conditions joined with &: workload >= 4 & workload < 7.', + 'Then factor(workload_level, levels = c("low", "medium", "high"), ordered = TRUE) fixes the order.', + ], + }, ]; diff --git a/src/content/guideIndex.ts b/src/content/guideIndex.ts index ba783d9..5c41d56 100644 --- a/src/content/guideIndex.ts +++ b/src/content/guideIndex.ts @@ -12,9 +12,10 @@ export const LESSON_KEYWORDS: Record = { '01-1': 'object variable script comment vector c() arithmetic', '01-2': 'function argument default help missing na.rm', '01-3': 'package library install tidyverse dplyr', - '02-1': 'read csv import data file read_csv factor levels str head', + '02-1': 'read csv import data file read_csv factor levels str head class type logical integer character spss sav haven excel readxl labels', '02-2': 'pipe filter select mutate arrange dplyr rows columns new variable sort', '02-3': 'wide long pivot pivot_longer reshape tidy data', + '02-4': 'missing na na_if is.na na.omit listwise pairwise recode case_when ordered factor dummy ifelse rename join left_join merge clean cleaning', '03-1': 'summary summarise mean median sd standard deviation spread describe descriptives', '03-2': 'group_by summary per group descriptives by group n count', '03-3': 'mean median outlier skew skewed', @@ -32,12 +33,12 @@ export const LESSON_KEYWORDS: Record = { '07-2': 'confidence interval ci meaning 95 interpretation', '07-3': 'error bars sd se ci plot', '08-1': 'null hypothesis null distribution test', - '08-2': 'p value pvalue alpha significance significant report', + '08-2': 'p value pvalue alpha significance significant report pt qt pchisq qchisq critical value table', '08-3': 'type i type ii error power false positive non significant', '08-4': 'effect size cohen d power sample size planning how many participants', '09-1': 'proportion percentage binom.test prop.test one proportion', - '09-2': 'table counts crosstab contingency frequencies', - '09-3': 'chi square chisq.test categorical association cramer v independence', + '09-2': 'table counts crosstab contingency frequencies tabyl janitor crosstab frequency table adorn', + '09-3': 'chi square chisq.test categorical association cramer v independence goodness of fit expected proportions', '10-1': 'correlation association relationship scatterplot cor pearson', '10-2': 'regression line lm slope intercept residuals predict simple regression', '10-3': 'regression output coefficients r squared assumptions report lm summary', @@ -45,8 +46,8 @@ export const LESSON_KEYWORDS: Record = { '11-1': 'multiple regression two predictors several predictors control', '11-2': 'controlling for holding constant confounder simpson paradox', '11-3': 'model comparison r squared change anova apa report hierarchical regression', - '11-4': 'assumptions residuals diagnostics normality influential outlier cook heteroscedasticity', - '12-1': 'ttest t test two groups compare means independent welch', + '11-4': 'assumptions residuals diagnostics normality influential outlier cook heteroscedasticity shapiro breusch pagan bptest vif multicollinearity heteroscedasticity', + '12-1': 'ttest t test two groups compare means independent welch levene welch equal variances', '12-2': 'anova three groups dummy coding factor compare means one way', '12-3': 'post hoc pairwise comparisons emmeans tukey bonferroni which groups differ', '12-4': 'kruskal wallis mann whitney wilcoxon nonparametric ranks groups', diff --git a/src/content/lessons/01-1-objects-and-scripts.mdx b/src/content/lessons/01-1-objects-and-scripts.mdx index 45d3359..6bd1627 100644 --- a/src/content/lessons/01-1-objects-and-scripts.mdx +++ b/src/content/lessons/01-1-objects-and-scripts.mdx @@ -32,7 +32,14 @@ repeating either number. Names are case sensitive: `hours` and `Hours` are two different objects, and asking for one when you stored the other is the first error almost everyone meets. A name may not start with a number and may not contain a space, which is -why R code is full of `mean_score` and `engagement_t1`. +why R code is full of `mean_score` and `engagement_t1`. BMS courses, like the +tidyverse style guide, use only lowercase letters, numbers and underscores in +names, such as `freq_table_1`. + +Every object you create is kept in the **global environment**: the collection of +everything your session has stored so far. In RStudio it is listed in the +Environment pane, top right. When R says "object not found", that list is the +first place to look. ## Comments @@ -44,6 +51,12 @@ doing. weekly_pay <- hours * rate weekly_pay`} /> +A readable script starts with a comment that says what it is for, and splits the +work into parts with a heading comment that ends in four dashes, such as +`# Read the data ----`. RStudio lets you fold and jump between those parts. Put +a space after every comma and around `<-`, `==` and `+`, just as you would put a +space after a comma in English. + **In RStudio,** `?round` opens the full help page: every argument, the value -> returned, and worked examples at the bottom. Here, `args()` gives you the -> argument list, which is what you need nine times out of ten. +> **In RStudio,** `?round` opens the full help page in the Help pane, bottom +> right: every argument, the value returned, and worked examples at the bottom. +> `??round` searches the help of every installed package, for when you do not +> know which function you need. Here, `args()` gives you the argument list, +> which is what you need nine times out of ten. ## Missing values diff --git a/src/content/lessons/01-3-packages-and-libraries.mdx b/src/content/lessons/01-3-packages-and-libraries.mdx index 00d1559..977e5e2 100644 --- a/src/content/lessons/01-3-packages-and-libraries.mdx +++ b/src/content/lessons/01-3-packages-and-libraries.mdx @@ -83,6 +83,26 @@ packages this course never touches, and everything here downloads into your browser. So each lesson attaches exactly what it needs, by name. The functions are identical either way. +## The packages BMS courses use + +BMS bases its R teaching on the tidyverse, plus a short list of specialised +packages. You will meet most of them in this course. + +| Package | What for | In this course | +|---|---|---| +| tidyverse | ggplot2, dplyr, tidyr, readr and the rest of the core | from Module 2 | +| readxl, haven | reading Excel and SPSS files (installed with the tidyverse, attached separately) | Lesson 2-4 | +| broom | model output as a tidy table with `tidy()` | from Module 10 | +| janitor | frequency tables and crosstabs with `tabyl()` | Lesson 9-2 | +| car | Levene's test and the variance inflation factor | Lessons 11-4 and 12-1 | +| lmtest | the Breusch-Pagan test for equal variances | Lesson 11-4 | +| lme4, lmerTest | linear mixed models, with approximate p-values | Module 14 | +| psych | reliability and factor analysis | Lessons 3-4 and 17-2 | + +Your course tells you which ones to install and attach at the start. Like broom, +readxl and haven are installed along with the tidyverse but are not attached by +`library(tidyverse)`, so each needs its own `library()` line. + + +- **logical**: TRUE or FALSE, for yes/no variables. +- **integer**: whole numbers, often counts. The `L` in `3L` makes one by hand. +- **numeric**: numbers in general, decimals included, for interval and ratio + variables. +- **character**: text. Fine for names and comments, but a categorical variable + should become a factor. +- **factor**: a fixed set of categories, for nominal variables. An **ordered + factor**, which Lesson 2-4 makes, is the version for ordinal variables. + +Because a vector holds one kind only, mixing them makes R convert: +`c(1, 2, "three")` turns all three into text. A number column that arrives as +character usually means one cell in the file holds something that is not a +number. + ## Counting and summarising % head(3)`} /> With one function the second form looks like showing off. With four it is the difference between code you can read and code you cannot. +In RStudio, **Ctrl + Shift + M** (Cmd + Shift + M on a Mac) types the pipe for +you. When a pipeline runs over several lines, end each line with `%>%` and +indent the next line by two spaces, as every example here does. + + +`na_if()` turns one chosen value into `NA`. In the same pipeline, `rename()` +gives `Age` a lowercase name. Note the order inside `rename()`: the new name +comes first, then the old one. + +% + rename(age = Age) %>% + mutate( + age = na_if(age, 99), + satisfaction = na_if(satisfaction, 99), + contract = na_if(contract, "Don't know") + ) + +survey`} /> + +## Counting the holes + +`is.na()` gives TRUE for every missing value, so the compare-then-sum idiom from +Module 1 counts them. `colSums()` does it for every column at once. + + + +## Pairwise or listwise + +There are two ways to deal with the missing values, and they give different +answers. + +- **Pairwise deletion** leaves a person out only of the calculations that need + the missing value. That is what `na.rm = TRUE` does. +- **Listwise deletion** drops every person who is missing anything, before any + calculation. That is what `na.omit()` does to a data frame. + + + +The mean age changed from 32.8 to 29.75, although nobody's age changed. Person +4 was dropped for a missing satisfaction score, and their age of 45 went with +them. Listwise deletion can throw away a lot of real data, so check how many +rows `na.omit()` leaves before you rely on it. To drop people only for one +variable, filter on it: `filter(!is.na(age))`, where `!` means "not". + + + +## From numbers to categories: case_when() + +`case_when()` recodes a range of values into a category. Each line is a +condition, a tilde `~`, and the value to give the rows where that condition +holds. Here tenure becomes three bands. + +% + mutate(tenure_band = case_when( + tenure_years < 2 ~ "new", + tenure_years >= 2 & tenure_years < 10 ~ "established", + tenure_years >= 10 ~ "long-serving" + )) + +table(employees$tenure_band)`} /> + +The counts are right, but the order is alphabetical: "established" before "new". +The new column is plain text, and text has no order of its own. + +## Ordered factors + +An **ordered factor** is a factor whose levels have a ranking, which is how R +stores an ordinal variable. `factor()` with `levels =` sets the order, and +`ordered = TRUE` says that order means something. + +% + mutate(tenure_band = factor(tenure_band, + levels = c("new", "established", "long-serving"), + ordered = TRUE + )) + +table(employees$tenure_band) +head(employees$tenure_band)`} /> + +The levels now print with `<` between them, and every table and plot follows +that order. A plain factor with `levels =` would also fix the order; `ordered = +TRUE` adds that comparisons such as `tenure_band >= "established"` make sense. + +## Dummy variables: ifelse() + +A dummy variable is 1 for one group and 0 for everyone else. `ifelse()` takes a +condition, the value for TRUE and the value for FALSE. + +% + mutate(remote_dummy = ifelse(remote == "Yes", 1, 0)) + +table(employees$remote, employees$remote_dummy)`} /> + +Every "Yes" became 1 and every "No" became 0. You rarely need to do this before +fitting a model, because `lm()` builds dummies from a factor by itself, as +Module 12 shows. It is useful when a dummy has to be a column you can see, sum or +average. + + + +## Adding a column from another table: left_join() + +Sometimes the information you need is in a second file. Here a small table says +which province each site is in. `left_join()` keeps every row of the first table +and adds the matching columns of the second, matched on the column named in +`by`. + +% + left_join(sites, by = "site") + +employees %>% select(employee_id, site, province) %>% head(3) +table(employees$province)`} /> + +The key has to be spelled the same way in both tables. A site written as +"zwolle" in one file and "Zwolle" in the other does not match, and those rows +get `NA` for province, without an error. After a join, check for new missing +values with `sum(is.na(employees$province))`. + +## Other file types + +`read.csv()`, which Lesson 2-1 used, is part of base R. The tidyverse +alternative `read_csv()`, from readr, does the same job and returns a tibble, a data frame that prints more +compactly. Excel and SPSS files need a package each. In RStudio, with the file +in your project's data folder: + +```r +library(readxl) +survey <- read_excel("data/survey.xlsx") + +library(haven) +survey <- read_sav("data/survey.sav") +``` + +An SPSS file often stores its codebook inside the data: a label for each +variable and for each value, such as 1 = "woman". `haven` keeps them as +attributes, which `attr()` shows: + +```r +attr(survey$gender, "label") # what the variable is +attr(survey$gender, "labels") # what each code means +``` + +`as_factor(survey$gender)`, from haven, turns the coded numbers into a factor +with those labels as levels. And in RStudio, `View(survey)`, with a capital V, +opens any data frame as a spreadsheet. + + diff --git a/src/content/lessons/03-4-scales-and-reliability.mdx b/src/content/lessons/03-4-scales-and-reliability.mdx index db8aaf8..3ead924 100644 --- a/src/content/lessons/03-4-scales-and-reliability.mdx +++ b/src/content/lessons/03-4-scales-and-reliability.mdx @@ -113,6 +113,11 @@ removed and why. In RStudio, `psych::alpha(items)` does all of this in one call, and it warns you when an item looks as if it still needs reversing. +BMS psychometrics courses go further with three more packages: CTT, whose +`itemAnalysis()` reports alpha, alpha if an item is dropped and the item-rest +correlations; Lambda4, whose `guttman()` gives Guttman's lambda 2, a reliability +coefficient that is never below alpha; and mirt, for item response theory models. + + +`lower.tail = FALSE` asks for the area above the value, which is the tail a test +statistic falls in. A *t*-test is two-sided, so its p-value is twice one tail; a +chi-square test only has an upper tail, so nothing is doubled. + ## Reporting it diff --git a/src/content/lessons/09-2-tables-of-counts.mdx b/src/content/lessons/09-2-tables-of-counts.mdx index efd5d54..d297fa3 100644 --- a/src/content/lessons/09-2-tables-of-counts.mdx +++ b/src/content/lessons/09-2-tables-of-counts.mdx @@ -48,6 +48,32 @@ the table reads as a comparison of rates. +## The same tables with tabyl() + +BMS courses make frequency tables and crosstabs with `tabyl()` from the janitor +package. It works in a pipe, and the `adorn_` functions add totals, percentages +and counts to the table it returns. + +% tabyl(department) + +# A crosstab with row percentages, a total row and the counts in brackets. +d %>% + tabyl(department, left_company) %>% + adorn_totals("row") %>% + adorn_percentages("row") %>% + adorn_pct_formatting(digits = 1) %>% + adorn_ns()`} /> + +The crosstab shows the same leaving rates as `prop.table(counts, margin = 1)`, +with the counts next to them. `adorn_percentages("col")` divides by columns +instead. Keep the steps in this order: totals before percentages, so the total +row gets its own percentage, then the formatting, then `adorn_ns()` to put the +counts back next to the percentages. + ## A picture of it A stacked bar where every bar reaches 100% shows the row proportions. Map the diff --git a/src/content/lessons/09-3-chi-square-test.mdx b/src/content/lessons/09-3-chi-square-test.mdx index 935da19..7f5c056 100644 --- a/src/content/lessons/09-3-chi-square-test.mdx +++ b/src/content/lessons/09-3-chi-square-test.mdx @@ -86,8 +86,28 @@ Rough benchmarks for a table with two columns: .10 is small, .30 medium, .50 large. They are a starting point, not a verdict; what counts as large depends on the field. +In RStudio, `cramerV(counts, ci = TRUE)` from the rcompanion package, which the +BMS reference guide uses, gives the same V with a confidence interval. + +## One variable against expected proportions + +The same statistic can test a single categorical variable against proportions +you state in advance. This is the **goodness-of-fit test**. Suppose the 480 +employees are a random sample from a larger company that says its four +departments are equally large. Do the counts fit that claim? + + + +The expected count is 480 ร— 0.25 = 120 for every department, and the observed +counts of 100 to 132 are no further from it than chance would easily produce: +ฯ‡ยฒ(3) = 5.07, p = .17. The proportions in `p` must add up to 1 and follow the +order of the table, which is the order of the levels. The degrees of freedom +are the number of categories minus 1. + ## When the test cannot be trusted The chi-square distribution is an approximation, and it needs enough data. diff --git a/src/content/lessons/11-4-checking-the-model.mdx b/src/content/lessons/11-4-checking-the-model.mdx index e130358..b86ec91 100644 --- a/src/content/lessons/11-4-checking-the-model.mdx +++ b/src/content/lessons/11-4-checking-the-model.mdx @@ -79,6 +79,33 @@ With 480 people the slopes and their intervals are still trustworthy despite skewed residuals: the Central Limit Theorem of Module 6 looks after the sampling distribution of each slope. Normality matters most in small samples. +## The same checks as tests + +The plots are the main check. BMS courses also ask for three tests that put a +number on what the plots show: the **Shapiro-Wilk test** for normal residuals, +the **Breusch-Pagan test** for equal spread (from the lmtest package), and the +**variance inflation factor**, VIF, for multicollinearity (from the car package). + + + +Both tests reject: W = .86 and BP = 57.46, df = 3, both p < .001. The +Shapiro-Wilk test confirms the skew from the Q-Q plot. The Breusch-Pagan test +says the spread of the residuals is not the same everywhere, and it is the same +tail again: refit without the 28 employees whose residual is below -15 and BP +drops to 4.40, p = .22. Both tests answer "is the assumption exactly true?", and +with 480 people they detect small departures too, so read them together with the +plots, not instead of them. + +The VIFs are all close to 1, so the three predictors carry largely separate +information. A VIF above 5, or 10 by a more lenient rule, means a predictor is +largely predictable from the others and its standard error is inflated, which +Lesson 11-1 called multicollinearity. + ## Influential cases A case is **influential** when leaving it out moves the fitted line. **Cook's diff --git a/src/content/lessons/12-1-two-groups.mdx b/src/content/lessons/12-1-two-groups.mdx index 5d30a9d..e6c2e25 100644 --- a/src/content/lessons/12-1-two-groups.mdx +++ b/src/content/lessons/12-1-two-groups.mdx @@ -76,6 +76,23 @@ tool and Welch's version is the honest one. Normality matters mainly at small *n*; with 480 employees the Central Limit Theorem from Module 6 covers moderate skew. +BMS courses also test the equal-spread assumption directly, with **Levene's +test** from the car package. Its null hypothesis is that the groups have the same +variance in the population. + + + +Levene's test is far from significant, F(1, 478) = 0.05, p = .82, so the pooled +test above is fine here. Welch's test, the second line, drops the equal-variance +assumption and gives t(364.31) = -2.64, p = .009: the same conclusion. Its sign +is negative because `t.test()` subtracts in level order, No minus Yes, where the +model reported Yes minus No. When Levene's test is significant, or the SDs and +group sizes are both clearly unequal, report Welch's version. + For a small sample with clear skew or extreme outliers, the rank-based alternative is the Mann-Whitney test, `wilcox.test(wellbeing ~ remote, data = d)` โ€” one line in the chooser, and not what this course teaches as its default. diff --git a/src/content/lessons/17-2-factor-analysis.mdx b/src/content/lessons/17-2-factor-analysis.mdx index 126c531..17740d3 100644 --- a/src/content/lessons/17-2-factor-analysis.mdx +++ b/src/content/lessons/17-2-factor-analysis.mdx @@ -41,6 +41,19 @@ keeps too many. In RStudio, `psych::fa.parallel(items)` runs parallel analysis, which compares the eigenvalues with those of random data and is the better guide. +Before the analysis, BMS courses also check that the items are suitable for one. +In RStudio, with the psych package attached: + +```r +KMO(items) # Kaiser-Meyer-Olkin measure +cortest.bartlett(cor(items), n = nrow(items)) # Bartlett's test of sphericity +``` + +The KMO measure says how much of the correlation the items share, from 0 to 1; +below about .5 the items have too little in common for a factor analysis. +Bartlett's test asks whether the correlation matrix differs from one with no +correlations at all, and should be significant. + each. - The chi-square test asks whether two factors reproduce the correlations. A large p-value, here .70 (ฯ‡ยฒ(4) = 2.22), means the test found no evidence that - two factors fall short, so two factors are an adequate description. + two factors fall short, so two factors are an adequate description. With a + large sample this test rejects even when the factors describe the data well, + so BMS courses ask you to base the decision on the scree plot and the loadings, + not on this p-value. diff --git a/src/content/manifest.ts b/src/content/manifest.ts index 8a4d27a..988090a 100644 --- a/src/content/manifest.ts +++ b/src/content/manifest.ts @@ -114,6 +114,12 @@ export const PLANNED_MODULES: ModuleMeta[] = [ file: '02-3-wide-and-long', exercises: ['m2-3-a'], }, + { + id: '02-4', + title: 'Missing values, recoding and joining', + file: '02-4-missing-and-recoding', + exercises: ['m2-4-a'], + }, ], }, { @@ -297,6 +303,7 @@ export const PLANNED_MODULES: ModuleMeta[] = [ title: 'Tables of counts', file: '09-2-tables-of-counts', exercises: ['m9-2-a'], + packages: ['janitor'], }, { id: '09-3', @@ -365,6 +372,7 @@ export const PLANNED_MODULES: ModuleMeta[] = [ title: 'Checking the model', file: '11-4-checking-the-model', exercises: ['m11-4-a', 'm11-4-b'], + packages: ['car', 'lmtest'], }, ], }, @@ -378,6 +386,7 @@ export const PLANNED_MODULES: ModuleMeta[] = [ title: 'Two groups', file: '12-1-two-groups', exercises: ['m12-1-a', 'm12-1-b'], + packages: ['car'], }, { id: '12-2', diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 81e35e6..3311972 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -56,7 +56,7 @@ export default function Home() {

University of Twente

StatLab

-

Data analysis and statistics in R for psychology and business students, in short, hands-on lessons.

+

Data analysis and statistics in R for BMS bachelor students, in short, hands-on lessons that follow the R used in the Research Methods and Statistics courses.

Everything runs in your browser. Nothing is installed, files you open stay in your browser and are never sent anywhere, and your progress stays on this computer.

{target && ( @@ -110,6 +110,14 @@ export default function Home() { ))} +
+

Help with your own research

+

+ StatLab teaches the methods. For advice on your own study, such as a design, a choice of analysis or an R or SPSS problem in a thesis, BMS students and staff can ask the{' '} + BMS Methodology Shop, which also keeps a step-by-step guide to choosing a statistical test. +

+
+

Your progress

{hasStorageFailed() diff --git a/src/r/packages.ts b/src/r/packages.ts index 9003190..ee9eea2 100644 --- a/src/r/packages.ts +++ b/src/r/packages.ts @@ -25,7 +25,7 @@ export const RECOMMENDED: PackageGroup[] = [ { name: 'readxl', what: 'Read Excel files (.xls and .xlsx)' }, { name: 'writexl', what: 'Save a data frame as an Excel file' }, { name: 'haven', what: 'Read SPSS, Stata and SAS files' }, - { name: 'janitor', what: 'Clean up column names and count categories' }, + { name: 'janitor', what: 'Frequency tables and crosstabs with tabyl(), and clean column names' }, ], }, { @@ -60,6 +60,8 @@ export const RECOMMENDED: PackageGroup[] = [ packages: [ { name: 'broom', what: 'Model results as tidy tables' }, { name: 'car', what: 'Type II and III ANOVA, Levene test, VIF' }, + { name: 'lmtest', what: 'Breusch-Pagan test and other model diagnostics' }, + { name: 'modelr', what: 'Add predictions and residuals to a data frame' }, { name: 'emmeans', what: 'Estimated marginal means and pairwise comparisons' }, { name: 'lme4', what: 'Mixed-effects (multilevel) models' }, { name: 'lmerTest', what: 'p-values for lme4 models' }, diff --git a/src/r/session.itest.ts b/src/r/session.itest.ts index 0b4078c..0c9812e 100644 --- a/src/r/session.itest.ts +++ b/src/r/session.itest.ts @@ -105,6 +105,28 @@ describe('the modelling packages', () => { // reshape only, and the chooser's mixed-model leaf becomes reference material // rather than a link to a lesson. Finding that out here costs one run; // finding it out at Module 14 costs the module. + // Lessons 9-2 and 11-4 run tabyl() and bptest(), the BMS reference guide's + // choices for crosstabs and the equal-variance test. + test('janitor and lmtest install, and the lesson calls run', async () => { + const real = new WebR(); + await real.init(); + try { + await ensurePackages(real, [...CORE_PACKAGES, 'janitor', 'lmtest']); + const result = await evaluateR( + real, + 'suppressMessages({ library(dplyr); library(janitor); library(lmtest) })\n' + + 'd <- data.frame(g = rep(c("a", "b"), 30), y = rep(0:1, each = 30), x = rnorm(60))\n' + + 't <- d %>% tabyl(g, y) %>% adorn_totals("row") %>% adorn_percentages("row") %>% adorn_pct_formatting(digits = 1) %>% adorn_ns()\n' + + 'b <- bptest(lm(x ~ g, data = d))\n' + + 'c(class(t)[1], class(b))', + ); + expect(result.errored, result.output.map((o) => o.data).join('\n')).toBe(false); + expect(result.output.map((o) => o.data).join('\n')).toContain('tabyl'); + } finally { + await real.close(); + } + }, 300_000); + test('emmeans, car, lme4 and lmerTest install and attach', async () => { const real = new WebR(); await real.init(); diff --git a/src/r/session.ts b/src/r/session.ts index e3c6440..501efc2 100644 --- a/src/r/session.ts +++ b/src/r/session.ts @@ -16,7 +16,7 @@ export const CORE_PACKAGES = ['dplyr', 'ggplot2', 'tidyr', 'broom'] as const; * declaring a package the course does not know about โ€” a typo in a manifest * entry would otherwise surface as a silent install failure mid-lesson. */ -export const ON_DEMAND_PACKAGES = ['emmeans', 'car', 'lme4', 'lmerTest'] as const; +export const ON_DEMAND_PACKAGES = ['emmeans', 'car', 'lme4', 'lmerTest', 'janitor', 'lmtest'] as const; export const KNOWN_PACKAGES = [...CORE_PACKAGES, ...ON_DEMAND_PACKAGES] as const; From da37da048c854b3caedd474a206e6ad8d98ae667 Mon Sep 17 00:00:00 2001 From: Peter Slijkhuis Date: Mon, 19 Oct 2026 09:22:03 +0000 Subject: [PATCH 2/8] Hide the advanced modules behind a Settings toggle The core course now follows the BMS Research Methods and Statistics material. Modules 16 and 17 go beyond it, so they appear in the sidebar, on the home page and in the progress totals only when Advanced topics is switched on under the new Settings group. Points still count every lesson, and a link to an advanced lesson still opens it. --- README.md | 4 ++-- src/components/Sidebar.test.tsx | 10 +++++++++ src/components/Sidebar.tsx | 15 +++++++++++--- src/content/parts.ts | 15 ++++++++++++-- src/pages/Home.tsx | 8 +++++--- src/pages/Lesson.tsx | 9 ++++++++- src/state/settings.ts | 36 +++++++++++++++++++++++++++++++++ src/state/stats.ts | 18 +++++++++-------- 8 files changed, 96 insertions(+), 19 deletions(-) create mode 100644 src/state/settings.ts diff --git a/README.md b/README.md index 0dcc0b5..a83fd01 100644 --- a/README.md +++ b/README.md @@ -177,7 +177,7 @@ their traditional names. | **Foundations** | | | | | 0 | Before you start | RStudio Projects, files, folders and paths, and what R's symbols mean, with a cheat sheet | | | 1 | First steps in R | Objects, functions, help, packages and `library()` | | -| 2 | Working with data | `read.csv`, factors, the pipe, `select`, `filter`, `mutate`, wide and long data | | +| 2 | Working with data | `read.csv`, factors and data types, the pipe, `select`, `filter`, `mutate`, wide and long data, missing values, recoding, joins, Excel and SPSS files | | | 3 | Describing data | `group_by` and `summarise`, mean versus median, surprises in a summary, scale scores and Cronbach's alpha | | | 4 | Visualising data | ggplot2 as layers, facets, and an APA-ready figure | | | **Inference** | | | | @@ -193,7 +193,7 @@ their traditional names. | 13 | Interactions and factorial designs | `a * b`, sum-to-zero contrasts, Type III tests with `car`, interaction plots | | | 14 | Repeated measures and nested data | `lmer` with `(1 \| id)`, fixed and random effects, the paired t-test, group by time designs, pre, mid and post, Wilcoxon and Friedman tests | | | 15 | Binary and count outcomes | `glm(..., family = binomial)`, log odds, odds ratios and reporting, Poisson regression for counts | | -| **Advanced** | | | | +| **Advanced** (switch on under Settings, Advanced topics) | | | | | 16 | Bayesian statistics | Prior, likelihood and posterior, credible intervals, Bayes factors, Bayesian regression | | | 17 | Mediation, factors and reports | Indirect effects with bootstrap intervals, exploratory factor analysis, reproducible reports with Quarto | | diff --git a/src/components/Sidebar.test.tsx b/src/components/Sidebar.test.tsx index 2e9d232..d70cfb8 100644 --- a/src/components/Sidebar.test.tsx +++ b/src/components/Sidebar.test.tsx @@ -49,6 +49,16 @@ describe('Sidebar', () => { expect(screen.getByText('The Central Limit Theorem')).toBeDefined(); }); + test('hides the advanced modules until the student switches them on', () => { + renderSidebar(); + expect(screen.queryByText('Advanced')).toBeNull(); + const toggle = screen.getByRole('button', { name: 'Advanced topics' }); + expect(toggle.getAttribute('aria-pressed')).toBe('false'); + act(() => toggle.click()); + expect(screen.getByText('Advanced')).toBeDefined(); + expect(screen.getByRole('button', { name: 'Advanced topics' }).getAttribute('aria-pressed')).toBe('true'); + }); + test('links each lesson to its route', () => { renderSidebar(); const link = screen.getByRole('link', { name: 'The sampling distribution' }); diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 3c39639..45df88f 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,7 +1,8 @@ import { useEffect, useRef, useState } from 'react'; import { NavLink, useLocation } from 'react-router-dom'; import { MODULES } from '../content/manifest'; -import { PARTS } from '../content/parts'; +import { visibleModules, visibleParts } from '../content/parts'; +import { setAdvanced, useAdvanced } from '../state/settings'; import { getProgress, subscribeProgress } from '../state/progress'; import { courseStats, lessonStatus, moduleOf, moduleProgress } from '../state/stats'; import { currentLook, pointsToSpend } from '../state/avatar'; @@ -50,7 +51,8 @@ export default function Sidebar({ open = false }: Props) { query.addEventListener('change', onChange); return () => query.removeEventListener('change', onChange); }, []); - const stats = courseStats(progress); + const advanced = useAdvanced(); + const stats = courseStats(progress, visibleModules(advanced).flatMap((module) => module.lessons)); // One module open at a time by default: the one holding the current lesson. // Opening another by hand keeps both open until the page changes. @@ -97,7 +99,7 @@ export default function Sidebar({ open = false }: Props) { {stats.lessonsComplete}/{stats.lessonsTotal}
- {PARTS.map((part) => ( + {visibleParts(advanced).map((part) => (

{part.title}

{MODULES.filter((module) => (part.modules as readonly number[]).includes(module.number)).map((module) => { @@ -150,7 +152,14 @@ export default function Sidebar({ open = false }: Props) {
  • How many participants?
  • Review quiz
  • R Workspace
  • + +
    + +
    +

    Settings

    +
    • +
    diff --git a/src/content/parts.ts b/src/content/parts.ts index 5ce6d14..bf1c093 100644 --- a/src/content/parts.ts +++ b/src/content/parts.ts @@ -1,4 +1,4 @@ -import type { ModuleMeta } from './manifest'; +import { MODULES, type ModuleMeta } from './manifest'; /** * Spec ยง7 groups the modules into three parts. Module 0 opens the first, and a @@ -8,9 +8,20 @@ export const PARTS = [ { title: 'Foundations', blurb: 'Setting up, R, data, summaries and plots', modules: [0, 1, 2, 3, 4] }, { title: 'Inference', blurb: 'From one sample to the population', modules: [5, 6, 7, 8, 9] }, { title: 'The linear model', blurb: 'One model, many names', modules: [10, 11, 12, 13, 14, 15] }, - { title: 'Advanced', blurb: 'Beyond the core course: Bayesian statistics, mediation, factor analysis and reports', modules: [16, 17] }, + { title: 'Advanced', blurb: 'Beyond the core course: Bayesian statistics, mediation, factor analysis and reports', modules: [16, 17], advanced: true }, ] as const; +/** The parts to show: the Advanced part only when the student switched it on. */ +export function visibleParts(advanced: boolean) { + return PARTS.filter((part) => advanced || !('advanced' in part)); +} + +/** The modules of the parts to show. */ +export function visibleModules(advanced: boolean): ModuleMeta[] { + const numbers = visibleParts(advanced).flatMap((part) => [...part.modules] as number[]); + return MODULES.filter((module) => numbers.includes(module.number)); +} + export function partOf(module: ModuleMeta): (typeof PARTS)[number] | undefined { return PARTS.find((part) => (part.modules as readonly number[]).includes(module.number)); } diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 3311972..6034259 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -3,7 +3,8 @@ import { Link } from 'react-router-dom'; import Credits from '../components/Credits'; import Logo from '../components/Logo'; import { ALL_LESSONS, findLesson, MODULES } from '../content/manifest'; -import { PARTS } from '../content/parts'; +import { visibleModules, visibleParts } from '../content/parts'; +import { useAdvanced } from '../state/settings'; import { exportProgress, getProgress, @@ -32,7 +33,8 @@ export default function Home() { const [, setTick] = useState(0); useEffect(() => subscribeProgress(() => setTick((tick) => tick + 1)), []); const progress = getProgress(); - const stats = courseStats(progress); + const advanced = useAdvanced(); + const stats = courseStats(progress, visibleModules(advanced).flatMap((module) => module.lessons)); const resumeId = lastVisitedLesson(); const resume = resumeId ? findLesson(resumeId) : undefined; // A returning student picks up where they were; a new one starts at lesson 1. @@ -77,7 +79,7 @@ export default function Home() {
  • {stats.exercisesPassed}/{stats.exercisesTotal}exercises solved
  • - {PARTS.map((part, partIndex) => ( + {visibleParts(advanced).map((part, partIndex) => (

    Part {partIndex + 1}

    diff --git a/src/pages/Lesson.tsx b/src/pages/Lesson.tsx index 8079089..33f2d45 100644 --- a/src/pages/Lesson.tsx +++ b/src/pages/Lesson.tsx @@ -7,6 +7,8 @@ import { useLessonSession } from '../r/useLessonSession'; import { LessonFinish, ReadingProgress, SectionGuide, useReveal } from '../components/LessonChrome'; import { getProgress, subscribeProgress, touchLesson } from '../state/progress'; import { exercisesPassed, lessonStatus, moduleOf } from '../state/stats'; +import { visibleModules } from '../content/parts'; +import { useAdvanced } from '../state/settings'; const lessonModules = import.meta.glob<{ default: ComponentType<{ components?: unknown }> }>( '../content/lessons/*.mdx', @@ -19,6 +21,7 @@ export default function Lesson() { const [Content, setContent] = useState | null>(null); const { webR, env } = useLessonSession(meta ?? null); const article = useRef(null); + const advanced = useAdvanced(); const [, setTick] = useState(0); useEffect(() => subscribeProgress(() => setTick((tick) => tick + 1)), []); useReveal(article, Content); @@ -54,7 +57,11 @@ export default function Lesson() { ); } - const { previous, next } = lessonNeighbours(meta.id); + const { previous, next: following } = lessonNeighbours(meta.id); + // The core course ends at Module 15 unless the student switched on the + // advanced topics; an advanced lesson opened by link still leads on. + const shown = (id: string) => visibleModules(advanced).some((module) => module.lessons.some((lesson) => lesson.id === id)); + const next = following && (shown(following.id) || !shown(meta.id)) ? following : undefined; const module = moduleOf(meta.id); const position = module ? module.lessons.findIndex((lesson) => lesson.id === meta.id) + 1 : 0; const progress = getProgress(); diff --git a/src/state/settings.ts b/src/state/settings.ts new file mode 100644 index 0000000..ff837df --- /dev/null +++ b/src/state/settings.ts @@ -0,0 +1,36 @@ +import { useSyncExternalStore } from 'react'; + +/** + * Whether the Advanced part of the course (Modules 16 and 17) is shown. The + * core course follows the BMS Research Methods and Statistics courses; the + * advanced modules go beyond them, so they stay hidden until a student asks. + */ +const ADVANCED_KEY = 'statlab.advanced'; + +const listeners = new Set<() => void>(); + +export function getAdvanced(): boolean { + try { + return localStorage.getItem(ADVANCED_KEY) === 'on'; + } catch { + return false; + } +} + +export function setAdvanced(on: boolean): void { + try { + localStorage.setItem(ADVANCED_KEY, on ? 'on' : 'off'); + } catch { + // Private mode: nothing is saved, and the setting falls back to off. + } + listeners.forEach((listener) => listener()); +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function useAdvanced(): boolean { + return useSyncExternalStore(subscribe, getAdvanced); +} diff --git a/src/state/stats.ts b/src/state/stats.ts index 0683bd0..c7669f8 100644 --- a/src/state/stats.ts +++ b/src/state/stats.ts @@ -42,19 +42,21 @@ export type CourseStats = { streak: number; }; -export function courseStats(progress: Progress): CourseStats { - const lessonsComplete = ALL_LESSONS.filter((lesson) => lessonStatus(lesson, progress) === 'complete').length; - const passed = ALL_LESSONS.reduce((sum, lesson) => sum + exercisesPassed(lesson, progress), 0); +export function courseStats(progress: Progress, lessons: LessonMeta[] = ALL_LESSONS): CourseStats { + const completeIn = (list: LessonMeta[]) => list.filter((lesson) => lessonStatus(lesson, progress) === 'complete').length; + const passedIn = (list: LessonMeta[]) => list.reduce((sum, lesson) => sum + exercisesPassed(lesson, progress), 0); const quizzesRight = Object.values(progress.lessons).reduce( (sum, lesson) => sum + Object.values(lesson.quizzes).filter(Boolean).length, 0, ); + // Points count every lesson, shown or not, so hiding a part never takes back + // points a student has already earned or spent. return { - lessonsComplete, - lessonsTotal: ALL_LESSONS.length, - exercisesPassed: passed, - exercisesTotal: ALL_LESSONS.reduce((sum, lesson) => sum + lesson.exercises.length, 0), - points: passed * POINTS.exercise + quizzesRight * POINTS.quiz + lessonsComplete * POINTS.lesson + (progress.bonus ?? 0), + lessonsComplete: completeIn(lessons), + lessonsTotal: lessons.length, + exercisesPassed: passedIn(lessons), + exercisesTotal: lessons.reduce((sum, lesson) => sum + lesson.exercises.length, 0), + points: passedIn(ALL_LESSONS) * POINTS.exercise + quizzesRight * POINTS.quiz + completeIn(ALL_LESSONS) * POINTS.lesson + (progress.bonus ?? 0), streak: currentStreak(progress), }; } From b6c5c41b83f76c5b8f8d06df36d88cf9cdc2785c Mon Sep 17 00:00:00 2001 From: Peter Slijkhuis Date: Mon, 19 Oct 2026 09:25:45 +0000 Subject: [PATCH 3/8] Credit the course materials and link each lesson to its chapters The credits and README now name the BMS Intro R reference guide, the Intro R course and the Methodology Shop next to van den Berg's book, and link the online version of the book. Each lesson shows which chapters of the book and of the BMS guide to read along. --- README.md | 8 ++++++++ src/App.css | 1 + src/components/Credits.tsx | 22 +++++++++++++++++++++- src/content/reading.ts | 24 ++++++++++++++++++++++++ src/pages/Lesson.test.tsx | 6 ++++++ src/pages/Lesson.tsx | 19 +++++++++++++++++++ 6 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 src/content/reading.ts diff --git a/README.md b/README.md index a83fd01..2a280c0 100644 --- a/README.md +++ b/README.md @@ -241,6 +241,14 @@ Theory, terminology and topic order follow [Creative Commons BY-NC-SA licence](https://creativecommons.org/licenses/by-nc-sa/4.0/). StatLab's lessons follow its theory, terminology and topic order but are written independently. +Every lesson links to the chapters to read along in the +[online book](https://019c2310-d1f3-c202-b65d-2af52db09a6c.share.connect.posit.cloud/). + +The R lessons follow the R taught in the BMS Research Methods and Statistics +courses: the reference guide *Intro R for BMS Bachelor programs* (CODE staff, +BMS) and the [Intro R](https://jsteinrucke-intro-r.share.connect.posit.cloud/) +course. For help with your own research, ask the +[BMS Methodology Shop](https://www.utwente.nl/en/bms/m-store/). RStudio is a trademark of Posit Software, PBC. StatLab is not affiliated with or endorsed by Posit. diff --git a/src/App.css b/src/App.css index 02af514..0f2e25a 100644 --- a/src/App.css +++ b/src/App.css @@ -316,6 +316,7 @@ button.sidebar-lesson { width: 100%; border: 0; background: none; font: inherit; .lesson-link.complete a::before { border-color: var(--success); background: var(--success); } .lesson-link a:hover { color: var(--brand); } +.lesson-reading { margin: 0.4rem 0 0; font-size: 0.9rem; color: var(--muted); } .home-help { margin-top: 3rem; padding: 1.4rem 1.5rem; border-radius: var(--radius); background: var(--surface); border: 1px solid var(--line); } .home-help h2 { margin-top: 0; font-size: 1.2rem; } .home-help p { color: var(--ink-2); margin-bottom: 0; } diff --git a/src/components/Credits.tsx b/src/components/Credits.tsx index 15d023a..c06ec88 100644 --- a/src/components/Credits.tsx +++ b/src/components/Credits.tsx @@ -23,6 +23,15 @@ export const REFERENCE_BOOK = { author: 'S.M. van den Berg', href: 'https://ris.utwente.nl/ws/portalfiles/portal/253344321/Analysing_data_using_linear_models_5th_Ed_January_2021.pdf', licenceHref: 'https://creativecommons.org/licenses/by-nc-sa/4.0/', + /** The book to read along in, online. */ + onlineHref: 'https://019c2310-d1f3-c202-b65d-2af52db09a6c.share.connect.posit.cloud/', +}; + +/** The R material BMS courses teach from, which the R lessons follow. */ +export const R_MATERIALS = { + guide: { title: 'Intro R for BMS Bachelor programs', author: 'CODE staff, BMS' }, + course: { title: 'Intro R', href: 'https://jsteinrucke-intro-r.share.connect.posit.cloud/' }, + shop: { title: 'BMS Methodology Shop', href: 'https://www.utwente.nl/en/bms/m-store/' }, }; export const PARTNERS = [ @@ -71,7 +80,18 @@ export default function Credits({ variant = 'full' }: Props) { Creative Commons BY-NC-SA licence - . StatLab's lessons are written independently. + . Read it along with the course in the{' '} + online book. + StatLab's lessons are written independently. +

    + )} + {variant === 'full' && ( +

    + The R lessons follow the R taught in the BMS Research Methods and Statistics courses: the reference guide{' '} + {R_MATERIALS.guide.title} ({R_MATERIALS.guide.author}) and the{' '} + {R_MATERIALS.course.title}{' '} + course. For help with your own research, ask the{' '} + {R_MATERIALS.shop.title}.

    )} {variant === 'full' && ( diff --git a/src/content/reading.ts b/src/content/reading.ts new file mode 100644 index 0000000..2104a05 --- /dev/null +++ b/src/content/reading.ts @@ -0,0 +1,24 @@ +/** + * Where each module's material sits in the course's sources: chapters of van + * den Berg's book, and of the BMS guide "Intro R for BMS Bachelor programs". + * Shown at the top of every lesson so a student can read along. + */ +export const READING: Record = { + 0: { guide: '1' }, + 1: { guide: '1' }, + 2: { book: '1', guide: '2' }, + 3: { book: '1', guide: '3' }, + 4: { book: '1', guide: '3 and 4' }, + 5: { book: '1' }, + 6: { book: '2' }, + 7: { book: '2' }, + 8: { book: '2' }, + 9: { book: '3 and 14', guide: '3 and 4' }, + 10: { book: '4, 5 and 8', guide: '4' }, + 11: { book: '4, 5 and 7', guide: '5' }, + 12: { book: '6 and 8', guide: '4' }, + 13: { book: '9' }, + 14: { book: '10, 11 and 12', guide: '5' }, + 15: { book: '13 and 14', guide: '5' }, + 17: { guide: '6' }, +}; diff --git a/src/pages/Lesson.test.tsx b/src/pages/Lesson.test.tsx index 951854e..09050a6 100644 --- a/src/pages/Lesson.test.tsx +++ b/src/pages/Lesson.test.tsx @@ -167,6 +167,12 @@ describe('Lesson lifecycle across a param-only navigation', () => { await waitFor(() => expect(screen.getByText('lesson-06-2-body ready=true')).toBeDefined()); }); + test('a lesson points to the book chapters to read along', () => { + render(); + expect(screen.getByText(/chapter 2/).textContent).toContain('Read along'); + expect(screen.getByRole('link', { name: 'Analysing Data Using Linear Models' }).getAttribute('href')).toContain('posit.cloud'); + }); + test('a lesson whose MDX file is missing still records a visit', async () => { render(); await waitFor(() => expect(screen.getByRole('heading', { name: 'Missing Lesson' })).toBeDefined()); diff --git a/src/pages/Lesson.tsx b/src/pages/Lesson.tsx index 33f2d45..4e940bf 100644 --- a/src/pages/Lesson.tsx +++ b/src/pages/Lesson.tsx @@ -9,6 +9,8 @@ import { getProgress, subscribeProgress, touchLesson } from '../state/progress'; import { exercisesPassed, lessonStatus, moduleOf } from '../state/stats'; import { visibleModules } from '../content/parts'; import { useAdvanced } from '../state/settings'; +import { READING } from '../content/reading'; +import { R_MATERIALS, REFERENCE_BOOK } from '../components/Credits'; const lessonModules = import.meta.glob<{ default: ComponentType<{ components?: unknown }> }>( '../content/lessons/*.mdx', @@ -90,6 +92,23 @@ export default function Lesson() {

    )} + {module && READING[module.number] && ( +

    + Read along:{' '} + {READING[module.number].book && ( + <> + {REFERENCE_BOOK.title} + , chapter{READING[module.number].book!.length > 2 ? 's' : ''} {READING[module.number].book} + + )} + {READING[module.number].book && READING[module.number].guide && '; '} + {READING[module.number].guide && ( + <> + {R_MATERIALS.guide.title}, chapter{READING[module.number].guide!.length > 2 ? 's' : ''} {READING[module.number].guide} + + )} +

    + )}
    {Content ? :

    Loading lessonโ€ฆ

    } From a31cc5ed409f590a49dcad2b05083ce4706190c3 Mon Sep 17 00:00:00 2001 From: Peter Slijkhuis Date: Mon, 19 Oct 2026 09:28:57 +0000 Subject: [PATCH 4/8] Link the online book's index page --- README.md | 2 +- src/components/Credits.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2a280c0..ce75675 100644 --- a/README.md +++ b/README.md @@ -242,7 +242,7 @@ Theory, terminology and topic order follow StatLab's lessons follow its theory, terminology and topic order but are written independently. Every lesson links to the chapters to read along in the -[online book](https://019c2310-d1f3-c202-b65d-2af52db09a6c.share.connect.posit.cloud/). +[online book](https://019c2310-d1f3-c202-b65d-2af52db09a6c.share.connect.posit.cloud/index.html). The R lessons follow the R taught in the BMS Research Methods and Statistics courses: the reference guide *Intro R for BMS Bachelor programs* (CODE staff, diff --git a/src/components/Credits.tsx b/src/components/Credits.tsx index c06ec88..233d3ce 100644 --- a/src/components/Credits.tsx +++ b/src/components/Credits.tsx @@ -24,7 +24,7 @@ export const REFERENCE_BOOK = { href: 'https://ris.utwente.nl/ws/portalfiles/portal/253344321/Analysing_data_using_linear_models_5th_Ed_January_2021.pdf', licenceHref: 'https://creativecommons.org/licenses/by-nc-sa/4.0/', /** The book to read along in, online. */ - onlineHref: 'https://019c2310-d1f3-c202-b65d-2af52db09a6c.share.connect.posit.cloud/', + onlineHref: 'https://019c2310-d1f3-c202-b65d-2af52db09a6c.share.connect.posit.cloud/index.html', }; /** The R material BMS courses teach from, which the R lessons follow. */ From 97689d939986e2c3c8a8762c89605610c0281ddd Mon Sep 17 00:00:00 2001 From: Peter Slijkhuis Date: Mon, 19 Oct 2026 09:35:08 +0000 Subject: [PATCH 5/8] Link every lesson to its own chapter of the online book Each core lesson now names the chapters of the online book, and where they apply the appendices on the normal and t-distributions, that cover it, linked to their pages, plus the matching chapters of the BMS guide. A test checks that all 16 chapters are read along with some lesson and that every lesson in Modules 0 to 15 has a reference. --- src/components/Credits.tsx | 3 +- src/content/reading.test.ts | 24 ++++++++ src/content/reading.ts | 113 +++++++++++++++++++++++++++++------- src/pages/Lesson.test.tsx | 4 +- src/pages/Lesson.tsx | 50 ++++++++++------ 5 files changed, 152 insertions(+), 42 deletions(-) create mode 100644 src/content/reading.test.ts diff --git a/src/components/Credits.tsx b/src/components/Credits.tsx index 233d3ce..60a4b5c 100644 --- a/src/components/Credits.tsx +++ b/src/components/Credits.tsx @@ -1,5 +1,6 @@ import type { MouseEvent } from 'react'; import { addBonusPoint } from '../state/progress'; +import { BOOK_BASE } from '../content/reading'; import './Credits.css'; // Logo files are picked up from src/assets/logos by name, so adding @@ -24,7 +25,7 @@ export const REFERENCE_BOOK = { href: 'https://ris.utwente.nl/ws/portalfiles/portal/253344321/Analysing_data_using_linear_models_5th_Ed_January_2021.pdf', licenceHref: 'https://creativecommons.org/licenses/by-nc-sa/4.0/', /** The book to read along in, online. */ - onlineHref: 'https://019c2310-d1f3-c202-b65d-2af52db09a6c.share.connect.posit.cloud/index.html', + onlineHref: `${BOOK_BASE}index.html`, }; /** The R material BMS courses teach from, which the R lessons follow. */ diff --git a/src/content/reading.test.ts b/src/content/reading.test.ts new file mode 100644 index 0000000..d515d04 --- /dev/null +++ b/src/content/reading.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from 'vitest'; +import { ALL_LESSONS, MODULES } from './manifest'; +import { BOOK_CHAPTERS, READING } from './reading'; + +describe('read-along references', () => { + test('every chapter of the online book is read along with at least one lesson', () => { + const used = new Set(Object.values(READING).flatMap((reading) => reading.book ?? [])); + BOOK_CHAPTERS.forEach((_, index) => expect(used, `chapter ${index + 1}`).toContain(index + 1)); + }); + + test('every reference names a real lesson and a real chapter', () => { + const ids = new Set(ALL_LESSONS.map((lesson) => lesson.id)); + for (const [id, reading] of Object.entries(READING)) { + expect(ids, id).toContain(id); + for (const chapter of reading.book ?? []) expect(chapter >= 1 && chapter <= BOOK_CHAPTERS.length, `${id} chapter ${chapter}`).toBe(true); + } + }); + + test('every lesson in the core course has something to read along', () => { + for (const module of MODULES.filter((m) => m.number <= 15)) { + for (const lesson of module.lessons) expect(READING[lesson.id], lesson.id).toBeDefined(); + } + }); +}); diff --git a/src/content/reading.ts b/src/content/reading.ts index 2104a05..05ca189 100644 --- a/src/content/reading.ts +++ b/src/content/reading.ts @@ -1,24 +1,95 @@ +/** The online version of van den Berg's book, one page per chapter. */ +export const BOOK_BASE = 'https://019c2310-d1f3-c202-b65d-2af52db09a6c.share.connect.posit.cloud/'; + +/** The online book's chapter pages, in order: chapter 1 is intro.html. */ +export const BOOK_CHAPTERS = [ + 'intro', 'mean', 'prop', 'simple', 'inf-lm', 'categorical', 'assumptions', 'nonpar1', + 'moderation', 'contrasts', 'post-hoc', 'mixed', 'premidpost', 'nonpar2', 'logistic', 'poissonrevised', +] as const; + +/** The online book's appendices. */ +export const BOOK_APPENDICES = { normal: 'appnormal', t: 'appt', matrix: 'matrix' } as const; + +export function bookChapterHref(chapter: number): string { + return `${BOOK_BASE}${BOOK_CHAPTERS[chapter - 1]}.html`; +} + +export type Reading = { book?: number[]; appendix?: (keyof typeof BOOK_APPENDICES)[]; guide?: string }; + /** - * Where each module's material sits in the course's sources: chapters of van - * den Berg's book, and of the BMS guide "Intro R for BMS Bachelor programs". - * Shown at the top of every lesson so a student can read along. + * Where each lesson's material sits in the course's sources: chapters of the + * online book, and of the BMS guide "Intro R for BMS Bachelor programs". + * Shown at the top of the lesson so a student can read along. A lesson the + * sources do not cover (the advanced modules, mostly) has no entry. */ -export const READING: Record = { - 0: { guide: '1' }, - 1: { guide: '1' }, - 2: { book: '1', guide: '2' }, - 3: { book: '1', guide: '3' }, - 4: { book: '1', guide: '3 and 4' }, - 5: { book: '1' }, - 6: { book: '2' }, - 7: { book: '2' }, - 8: { book: '2' }, - 9: { book: '3 and 14', guide: '3 and 4' }, - 10: { book: '4, 5 and 8', guide: '4' }, - 11: { book: '4, 5 and 7', guide: '5' }, - 12: { book: '6 and 8', guide: '4' }, - 13: { book: '9' }, - 14: { book: '10, 11 and 12', guide: '5' }, - 15: { book: '13 and 14', guide: '5' }, - 17: { guide: '6' }, +export const READING: Record = { + '00-1': { guide: '1' }, + '00-2': { guide: '1' }, + '00-3': { guide: '1' }, + '00-4': { guide: '1' }, + '01-1': { guide: '1' }, + '01-2': { guide: '1' }, + '01-3': { guide: '1' }, + '02-1': { book: [1], guide: '1 and 2' }, + '02-2': { guide: '1 and 2' }, + '02-3': { book: [1], guide: '2' }, + '02-4': { guide: '2' }, + '03-1': { book: [1], guide: '3' }, + '03-2': { book: [1], guide: '3' }, + '03-3': { book: [1], guide: '3' }, + '03-4': { guide: '6' }, + '04-1': { book: [1], guide: '3' }, + '04-2': { book: [1], guide: '4 and 5' }, + '04-3': { book: [1], guide: '4' }, + '05-1': { book: [1], appendix: ['normal'] }, + '05-2': { book: [1], appendix: ['normal'] }, + '05-3': { book: [1], appendix: ['normal'] }, + '06-1': { book: [2] }, + '06-2': { book: [2] }, + '06-3': { book: [2] }, + '07-1': { book: [2], appendix: ['t'] }, + '07-2': { book: [2] }, + '07-3': { book: [2] }, + '08-1': { book: [2] }, + '08-2': { book: [2], appendix: ['t'], guide: '3' }, + '08-3': { book: [2] }, + '08-4': { book: [2] }, + '09-1': { book: [3], guide: '3' }, + '09-2': { book: [1], guide: '4' }, + '09-3': { book: [16], guide: '3 and 4' }, + '10-1': { book: [4], guide: '4' }, + '10-2': { book: [4], guide: '4' }, + '10-3': { book: [5], guide: '5' }, + '10-4': { book: [8], guide: '4' }, + '11-1': { book: [4], guide: '5' }, + '11-2': { book: [4] }, + '11-3': { book: [5], guide: '5' }, + '11-4': { book: [7], guide: '5' }, + '12-1': { book: [6], guide: '4' }, + '12-2': { book: [6, 10], guide: '4' }, + '12-3': { book: [11] }, + '12-4': { book: [8], guide: '4' }, + '13-1': { book: [9] }, + '13-2': { book: [9, 10] }, + '13-3': { book: [9] }, + '14-1': { book: [12] }, + '14-2': { book: [12], guide: '5' }, + '14-3': { book: [12], guide: '5' }, + '14-4': { book: [13], guide: '5' }, + '14-5': { book: [14], guide: '5' }, + '15-1': { book: [15] }, + '15-2': { book: [15], guide: '5' }, + '15-3': { book: [15] }, + '15-4': { book: [16], guide: '5' }, + '17-2': { guide: '6' }, +}; + +export function bookAppendixHref(key: keyof typeof BOOK_APPENDICES): string { + return `${BOOK_BASE}${BOOK_APPENDICES[key]}.html`; +} + +export const APPENDIX_NAMES: Record = { + normal: 'appendix on the normal distribution', + t: 'appendix on the t-distribution', + matrix: 'appendix on matrix algebra', }; diff --git a/src/pages/Lesson.test.tsx b/src/pages/Lesson.test.tsx index 09050a6..762db59 100644 --- a/src/pages/Lesson.test.tsx +++ b/src/pages/Lesson.test.tsx @@ -169,8 +169,8 @@ describe('Lesson lifecycle across a param-only navigation', () => { test('a lesson points to the book chapters to read along', () => { render(); - expect(screen.getByText(/chapter 2/).textContent).toContain('Read along'); - expect(screen.getByRole('link', { name: 'Analysing Data Using Linear Models' }).getAttribute('href')).toContain('posit.cloud'); + expect(screen.getByText(/Read along/).textContent).toContain('Analysing Data Using Linear Models'); + expect(screen.getByRole('link', { name: 'chapter 2' }).getAttribute('href')).toMatch(/posit\.cloud\/mean\.html$/); }); test('a lesson whose MDX file is missing still records a visit', async () => { diff --git a/src/pages/Lesson.tsx b/src/pages/Lesson.tsx index 4e940bf..19c7ded 100644 --- a/src/pages/Lesson.tsx +++ b/src/pages/Lesson.tsx @@ -9,7 +9,7 @@ import { getProgress, subscribeProgress, touchLesson } from '../state/progress'; import { exercisesPassed, lessonStatus, moduleOf } from '../state/stats'; import { visibleModules } from '../content/parts'; import { useAdvanced } from '../state/settings'; -import { READING } from '../content/reading'; +import { APPENDIX_NAMES, bookAppendixHref, bookChapterHref, READING, type Reading } from '../content/reading'; import { R_MATERIALS, REFERENCE_BOOK } from '../components/Credits'; const lessonModules = import.meta.glob<{ default: ComponentType<{ components?: unknown }> }>( @@ -92,23 +92,7 @@ export default function Lesson() {

    )} - {module && READING[module.number] && ( -

    - Read along:{' '} - {READING[module.number].book && ( - <> - {REFERENCE_BOOK.title} - , chapter{READING[module.number].book!.length > 2 ? 's' : ''} {READING[module.number].book} - - )} - {READING[module.number].book && READING[module.number].guide && '; '} - {READING[module.number].guide && ( - <> - {R_MATERIALS.guide.title}, chapter{READING[module.number].guide!.length > 2 ? 's' : ''} {READING[module.number].guide} - - )} -

    - )} + {READING[meta.id] && } {Content ? :

    Loading lessonโ€ฆ

    } @@ -125,3 +109,33 @@ export default function Lesson() { ); } + +/** The chapters of the book and the BMS guide that cover this lesson. */ +function ReadAlong({ reading }: { reading: Reading }) { + const links = [ + ...(reading.book ?? []).map((chapter) => ({ href: bookChapterHref(chapter), text: `chapter ${chapter}` })), + ...(reading.appendix ?? []).map((key) => ({ href: bookAppendixHref(key), text: APPENDIX_NAMES[key] })), + ]; + return ( +

    + Read along:{' '} + {links.length > 0 && ( + <> + {REFERENCE_BOOK.title},{' '} + {links.map((link, index) => ( + + {index > 0 && ', '} + {link.text} + + ))} + + )} + {links.length > 0 && reading.guide && '; '} + {reading.guide && ( + <> + {R_MATERIALS.guide.title}, chapter{reading.guide.length > 2 ? 's' : ''} {reading.guide} + + )} +

    + ); +} From 352984e4d67f03a68f5af26db7c91c5292df3720 Mon Sep 17 00:00:00 2001 From: Peter Slijkhuis Date: Mon, 19 Oct 2026 09:44:55 +0000 Subject: [PATCH 6/8] Put everything beyond the book and the BMS course behind Advanced topics Factor analysis is in the BMS guide, so Lesson 17-2 moves into Module 3 (keeping its id, so saved progress still finds it) and Module 17 becomes Mediation and reports. Sections the sources do not cover, Fisher's exact test, robust and bootstrap intervals and nested random effects, are now wrapped in an Advanced block that shows a one-line note until Advanced topics is on. The model chooser lists only the models the core course teaches, and an advanced answer says how to see it. The README gains the chapter map. --- README.md | 44 ++++++++++++++++++- src/App.css | 3 ++ src/components/Advanced.test.tsx | 14 ++++++ src/components/Advanced.tsx | 25 +++++++++++ src/content/lessons/09-3-chi-square-test.mdx | 9 +++- .../lessons/11-4-checking-the-model.mdx | 16 ++++--- .../lessons/14-3-nesting-and-paired-t.mdx | 5 +++ src/content/manifest.ts | 16 ++++--- src/content/mdxComponents.tsx | 2 + src/content/parts.ts | 2 +- src/pages/ModelChooser.test.tsx | 16 ++++++- src/pages/ModelChooser.tsx | 30 +++++++++++-- 12 files changed, 161 insertions(+), 21 deletions(-) create mode 100644 src/components/Advanced.test.tsx create mode 100644 src/components/Advanced.tsx diff --git a/README.md b/README.md index ce75675..3d1572e 100644 --- a/README.md +++ b/README.md @@ -178,7 +178,7 @@ their traditional names. | 0 | Before you start | RStudio Projects, files, folders and paths, and what R's symbols mean, with a cheat sheet | | | 1 | First steps in R | Objects, functions, help, packages and `library()` | | | 2 | Working with data | `read.csv`, factors and data types, the pipe, `select`, `filter`, `mutate`, wide and long data, missing values, recoding, joins, Excel and SPSS files | | -| 3 | Describing data | `group_by` and `summarise`, mean versus median, surprises in a summary, scale scores and Cronbach's alpha | | +| 3 | Describing data | `group_by` and `summarise`, mean versus median, surprises in a summary, scale scores and Cronbach's alpha, exploratory factor analysis | | | 4 | Visualising data | ggplot2 as layers, facets, and an APA-ready figure | | | **Inference** | | | | | 5 | The normal distribution | Density, z-scores and probabilities | Distribution | @@ -195,7 +195,47 @@ their traditional names. | 15 | Binary and count outcomes | `glm(..., family = binomial)`, log odds, odds ratios and reporting, Poisson regression for counts | | | **Advanced** (switch on under Settings, Advanced topics) | | | | | 16 | Bayesian statistics | Prior, likelihood and posterior, credible intervals, Bayes factors, Bayesian regression | | -| 17 | Mediation, factors and reports | Indirect effects with bootstrap intervals, exploratory factor analysis, reproducible reports with Quarto | | +| 17 | Mediation and reports | Indirect effects with bootstrap intervals, reproducible reports with Quarto | | + +Modules 0 to 15 follow van den Berg's book and the R taught in the BMS +courses. Everything beyond them, the Advanced modules and a few sections +inside core lessons (Fisher's exact test, robust and bootstrap intervals, +nested random effects) and the model chooser's answers the course does not +teach, appears only after switching on **Advanced topics** under Settings. + +### Read along in the book + +Each lesson links to the chapter of the +[online book](https://019c2310-d1f3-c202-b65d-2af52db09a6c.share.connect.posit.cloud/index.html) +that covers it. The map lives in `src/content/reading.ts`, and a test checks +that every chapter is used. + +| Ch | Topic (links to the chapter) | StatLab lessons | +|---|---|---| +| 1 | [Variables, variation and co-variation](https://019c2310-d1f3-c202-b65d-2af52db09a6c.share.connect.posit.cloud/intro.html) | 2-1, 2-3, 3-1 to 3-3, 4-1 to 4-3, 5-1 to 5-3, 9-2 | +| 2 | [Inference about a mean](https://019c2310-d1f3-c202-b65d-2af52db09a6c.share.connect.posit.cloud/mean.html) | 6-1 to 8-4 | +| 3 | [Inference about a proportion](https://019c2310-d1f3-c202-b65d-2af52db09a6c.share.connect.posit.cloud/prop.html) | 9-1 | +| 4 | [Simple and multiple regression](https://019c2310-d1f3-c202-b65d-2af52db09a6c.share.connect.posit.cloud/simple.html) | 10-1, 10-2, 11-1, 11-2 | +| 5 | [Inference for linear models](https://019c2310-d1f3-c202-b65d-2af52db09a6c.share.connect.posit.cloud/inf-lm.html) | 10-3, 11-3 | +| 6 | [Categorical predictors](https://019c2310-d1f3-c202-b65d-2af52db09a6c.share.connect.posit.cloud/categorical.html) | 12-1, 12-2 | +| 7 | [Assumptions](https://019c2310-d1f3-c202-b65d-2af52db09a6c.share.connect.posit.cloud/assumptions.html) | 11-4 | +| 8 | [Non-parametric alternatives](https://019c2310-d1f3-c202-b65d-2af52db09a6c.share.connect.posit.cloud/nonpar1.html) | 10-4, 12-4 | +| 9 | [Moderation](https://019c2310-d1f3-c202-b65d-2af52db09a6c.share.connect.posit.cloud/moderation.html) | 13-1 to 13-3 | +| 10 | [Contrasts](https://019c2310-d1f3-c202-b65d-2af52db09a6c.share.connect.posit.cloud/contrasts.html) | 12-2, 13-2 | +| 11 | [Post-hoc comparisons](https://019c2310-d1f3-c202-b65d-2af52db09a6c.share.connect.posit.cloud/post-hoc.html) | 12-3 | +| 12 | [Linear mixed models](https://019c2310-d1f3-c202-b65d-2af52db09a6c.share.connect.posit.cloud/mixed.html) | 14-1 to 14-3 | +| 13 | [Pre, mid and post designs](https://019c2310-d1f3-c202-b65d-2af52db09a6c.share.connect.posit.cloud/premidpost.html) | 14-4 | +| 14 | [Non-parametric tests for repeated measures](https://019c2310-d1f3-c202-b65d-2af52db09a6c.share.connect.posit.cloud/nonpar2.html) | 14-5 | +| 15 | [Logistic regression](https://019c2310-d1f3-c202-b65d-2af52db09a6c.share.connect.posit.cloud/logistic.html) | 15-1 to 15-3 | +| 16 | [Poisson regression and chi-square](https://019c2310-d1f3-c202-b65d-2af52db09a6c.share.connect.posit.cloud/poissonrevised.html) | 9-3, 15-4 | +| A | [The normal distribution](https://019c2310-d1f3-c202-b65d-2af52db09a6c.share.connect.posit.cloud/appnormal.html) | 5-1 to 5-3 | +| A | [The t-distribution](https://019c2310-d1f3-c202-b65d-2af52db09a6c.share.connect.posit.cloud/appt.html) | 7-1, 8-2 | + +Chapters of the BMS guide *Intro R for BMS Bachelor programs*: 1 (R and +RStudio) goes with Modules 0 to 2, 2 (handling data files) with Module 2, +3 and 4 (univariate and bivariate analysis) with Modules 3, 4, 9, 10 and 12, +5 (multivariate analysis) with Modules 11, 14 and 15, and 6 (psychometrics) +with Lessons 3-4 and 17-2. ### How a lesson works diff --git a/src/App.css b/src/App.css index 0f2e25a..c01a2ef 100644 --- a/src/App.css +++ b/src/App.css @@ -317,6 +317,9 @@ button.sidebar-lesson { width: 100%; border: 0; background: none; font: inherit; .lesson-link a:hover { color: var(--brand); } .lesson-reading { margin: 0.4rem 0 0; font-size: 0.9rem; color: var(--muted); } +.advanced-note { margin: 1.2rem 0; padding: 0.6rem 0.9rem; border: 1px dashed var(--line-strong); border-radius: var(--radius-sm); color: var(--muted); font-size: 0.9rem; } +.advanced-section { margin: 1.2rem 0; padding: 0.2rem 1rem 0.6rem; border-left: 3px solid var(--line-strong); } +.advanced-label { margin: 0.6rem 0 0; color: var(--muted); font-size: 0.85rem; font-weight: 700; } .home-help { margin-top: 3rem; padding: 1.4rem 1.5rem; border-radius: var(--radius); background: var(--surface); border: 1px solid var(--line); } .home-help h2 { margin-top: 0; font-size: 1.2rem; } .home-help p { color: var(--ink-2); margin-bottom: 0; } diff --git a/src/components/Advanced.test.tsx b/src/components/Advanced.test.tsx new file mode 100644 index 0000000..47594e1 --- /dev/null +++ b/src/components/Advanced.test.tsx @@ -0,0 +1,14 @@ +import { act, render, screen } from '@testing-library/react'; +import { beforeEach, expect, test } from 'vitest'; +import Advanced from './Advanced'; +import { setAdvanced } from '../state/settings'; + +beforeEach(() => localStorage.clear()); + +test('an advanced section stays hidden behind a note until Advanced topics is on', () => { + render(

    Fisher's exact test

    ); + expect(screen.queryByText("Fisher's exact test")).toBeNull(); + expect(screen.getByText(/Switch on Advanced topics under Settings/)).toBeDefined(); + act(() => setAdvanced(true)); + expect(screen.getByText("Fisher's exact test")).toBeDefined(); +}); diff --git a/src/components/Advanced.tsx b/src/components/Advanced.tsx new file mode 100644 index 0000000..2561d1f --- /dev/null +++ b/src/components/Advanced.tsx @@ -0,0 +1,25 @@ +import type { ReactNode } from 'react'; +import { useAdvanced } from '../state/settings'; + +/** + * A part of a lesson that goes beyond van den Berg's book and the BMS course + * materials. Shown only with Advanced topics switched on under Settings; until + * then a one-line note marks where it is. Never wrap an Exercise in it: a + * hidden exercise would keep the lesson from ever completing. + */ +export default function Advanced({ children }: { children: ReactNode }) { + const advanced = useAdvanced(); + if (!advanced) { + return ( +

    + An advanced section, beyond the book and the BMS course, is hidden here. Switch on Advanced topics under Settings to read it. +

    + ); + } + return ( +
    +

    Advanced: beyond the book and the BMS course

    + {children} +
    + ); +} diff --git a/src/content/lessons/09-3-chi-square-test.mdx b/src/content/lessons/09-3-chi-square-test.mdx index 7f5c056..1242b6d 100644 --- a/src/content/lessons/09-3-chi-square-test.mdx +++ b/src/content/lessons/09-3-chi-square-test.mdx @@ -114,7 +114,12 @@ The chi-square distribution is an approximation, and it needs enough data. The usual rule is an **expected** count of at least 5 in every cell, or at least in 80% of them, none below 1. `chisq.test()` warns you ("Chi-squared approximation may be incorrect") as soon as any expected count is below 5, which is stricter than the 80% rule. -Then use Fisher's exact test, which makes no such approximation. +Then the p-value cannot be trusted, and the table needs more data, or fewer +categories by merging small ones. + + + +Another option is Fisher's exact test, which makes no such approximation. + The test also needs each person counted once, in one cell. A table of 480 answers from 120 people, each asked four times, breaks that. Module 14 builds the models that handle repeated answers. diff --git a/src/content/lessons/11-4-checking-the-model.mdx b/src/content/lessons/11-4-checking-the-model.mdx index b86ec91..1020c5c 100644 --- a/src/content/lessons/11-4-checking-the-model.mdx +++ b/src/content/lessons/11-4-checking-the-model.mdx @@ -150,11 +150,15 @@ distance tells you where to look, not what to throw away. - **Curve in the residuals:** the relationship is not a straight line. Add a squared term, `I(x^2)`, or transform a skewed predictor with `log()`. - **Funnel:** the spread grows with the fitted values. Transforming a skewed, - positive outcome with `log()` often fixes it, and robust standard errors - (the `sandwich` package, in RStudio) keep the slopes and repair their SEs. -- **Small sample, clearly non-normal residuals:** bootstrap the interval. - Resample employees with replacement, refit, and take the middle 95% of the - slopes. + positive outcome with `log()` often fixes it. + + + +Two more remedies go beyond the course. Robust standard errors (the `sandwich` +package, in RStudio) keep the slopes and repair their SEs when the spread is +unequal. For a small sample with clearly non-normal residuals, bootstrap the +interval: resample employees with replacement, refit, and take the middle 95% +of the slopes. + For a simple comparison of two groups there are rank-based tests too: `wilcox.test()` for two groups and `kruskal.test()` for several. They answer a slightly different question (about ranks, not means), so use them when the diff --git a/src/content/lessons/14-3-nesting-and-paired-t.mdx b/src/content/lessons/14-3-nesting-and-paired-t.mdx index e1d3d13..61cf5c5 100644 --- a/src/content/lessons/14-3-nesting-and-paired-t.mdx +++ b/src/content/lessons/14-3-nesting-and-paired-t.mdx @@ -98,6 +98,8 @@ Those six numbers are the estimated site deviations. They are shrunk towards zer because the model trusts a small sample less. That shrinkage is a feature, and it is one of the things a set of six fixed dummy coefficients would not give you. + + ## Both at once When employees are measured repeatedly **and** grouped in sites, the groupings @@ -144,3 +146,6 @@ fixed effects instead. { text: 'Engagement increased by 1.73 points, and since SD_site was smallest, site had no effect on engagement.', response: 'A variance component is not a test. A site SD near 0 says these six sites hardly differ in baseline engagement; it says nothing about other outcomes, and employees are still nested in sites.' }, ]} /> + + + diff --git a/src/content/manifest.ts b/src/content/manifest.ts index 988090a..86114be 100644 --- a/src/content/manifest.ts +++ b/src/content/manifest.ts @@ -151,6 +151,14 @@ export const PLANNED_MODULES: ModuleMeta[] = [ file: '03-4-scales-and-reliability', exercises: ['m3-4-a', 'm3-4-b'], }, + { + // Kept its id from when it sat in Module 17, so saved progress still + // finds it. The BMS guide teaches factor analysis, so it is core. + id: '17-2', + title: 'Exploratory factor analysis', + file: '17-2-factor-analysis', + exercises: ['m17-2-a'], + }, ], }, { @@ -540,7 +548,7 @@ export const PLANNED_MODULES: ModuleMeta[] = [ { id: 'module-17', number: 17, - title: 'Mediation, factors and reports', + title: 'Mediation and reports', lessons: [ { id: '17-1', @@ -548,12 +556,6 @@ export const PLANNED_MODULES: ModuleMeta[] = [ file: '17-1-mediation', exercises: ['m17-1-a'], }, - { - id: '17-2', - title: 'Exploratory factor analysis', - file: '17-2-factor-analysis', - exercises: ['m17-2-a'], - }, { id: '17-3', title: 'Reproducible reports with Quarto', diff --git a/src/content/mdxComponents.tsx b/src/content/mdxComponents.tsx index 5187f42..47a0a86 100644 --- a/src/content/mdxComponents.tsx +++ b/src/content/mdxComponents.tsx @@ -1,3 +1,4 @@ +import Advanced from '../components/Advanced'; import CodeBlock from '../components/CodeBlock'; import Exercise from '../components/Exercise'; import FolderTree from '../components/FolderTree'; @@ -9,6 +10,7 @@ import Simulation from '../components/Simulation'; import SymbolTable from '../components/SymbolTable'; export const mdxComponents = { + Advanced, CodeBlock, Exercise, FolderTree, diff --git a/src/content/parts.ts b/src/content/parts.ts index bf1c093..1ea1163 100644 --- a/src/content/parts.ts +++ b/src/content/parts.ts @@ -8,7 +8,7 @@ export const PARTS = [ { title: 'Foundations', blurb: 'Setting up, R, data, summaries and plots', modules: [0, 1, 2, 3, 4] }, { title: 'Inference', blurb: 'From one sample to the population', modules: [5, 6, 7, 8, 9] }, { title: 'The linear model', blurb: 'One model, many names', modules: [10, 11, 12, 13, 14, 15] }, - { title: 'Advanced', blurb: 'Beyond the core course: Bayesian statistics, mediation, factor analysis and reports', modules: [16, 17], advanced: true }, + { title: 'Advanced', blurb: 'Beyond the book and the BMS course: Bayesian statistics, mediation and reports', modules: [16, 17], advanced: true }, ] as const; /** The parts to show: the Advanced part only when the student switched it on. */ diff --git a/src/pages/ModelChooser.test.tsx b/src/pages/ModelChooser.test.tsx index 91d4f82..5f8ad13 100644 --- a/src/pages/ModelChooser.test.tsx +++ b/src/pages/ModelChooser.test.tsx @@ -3,13 +3,17 @@ import { StrictMode } from 'react'; import { render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MemoryRouter, useLocation, useNavigate } from 'react-router-dom'; -import { describe, expect, test } from 'vitest'; +import { beforeEach, describe, expect, test } from 'vitest'; +import { setAdvanced } from '../state/settings'; import { findLesson } from '../content/manifest'; import { DATASET_FILES } from '../r/session'; import ModelChooser, { TREE, WhereItRuns, type Answer, type Node } from './ModelChooser'; import { browserSupport } from '../r/packages'; import { allAnswers, GROUPS as SECTIONS, packagesIn, packagesMissingHere } from './modelTree'; +// Most tests walk to every answer, the advanced ones included. +beforeEach(() => setAdvanced(true)); + function Location() { const location = useLocation(); return

    {location.pathname + location.search}

    ; @@ -578,3 +582,13 @@ test("the browser's Back button goes back one question, not off the page", async expect(heading().textContent).toBe(first); expect(screen.getByTestId('location').textContent).toBe('/which-model'); }); + +describe('without Advanced topics', () => { + test('the list holds only what the core course teaches, and an advanced answer says how to see it', async () => { + setAdvanced(false); + renderChooser('/which-model?model=mediation'); + expect(screen.getByText(/goes beyond the book and the BMS course/)).toBeDefined(); + expect(screen.queryByRole('button', { name: 'Beyond this course' })).toBeNull(); + expect(screen.getByText(/switch on Advanced topics under Settings for the rest/)).toBeDefined(); + }); +}); diff --git a/src/pages/ModelChooser.tsx b/src/pages/ModelChooser.tsx index 51a0fb5..f239d73 100644 --- a/src/pages/ModelChooser.tsx +++ b/src/pages/ModelChooser.tsx @@ -3,8 +3,26 @@ import { Link, useSearchParams } from 'react-router-dom'; import { findLesson } from '../content/manifest'; import { allAnswers, follow, GROUPS, packagesDownloaded, packagesMissingHere, pathTo, SHIPS_WITH_R, TREE, type Answer, type Node } from './modelTree'; import { CUES, matchQuestion, type CueId } from './questionMatcher'; +import { visibleModules } from '../content/parts'; +import { useAdvanced } from '../state/settings'; import './ModelChooser.css'; +/** Lessons in the core course, the part shown without Advanced topics. */ +const CORE_LESSONS = new Set(visibleModules(false).flatMap((module) => module.lessons.map((lesson) => lesson.id))); + +/** Whether the course itself teaches this model, outside the advanced modules. */ +export function inCore(answer: Answer): boolean { + return Boolean(answer.lessonId && CORE_LESSONS.has(answer.lessonId)); +} + +function BeyondNote({ answer }: { answer: Answer }) { + return ( +

    + {answer.model} goes beyond the book and the BMS course. Switch on Advanced topics under Settings to see when to use it and how to run it. +

    + ); +} + export { TREE, type Answer, type Node } from './modelTree'; /** @@ -75,6 +93,8 @@ function AnswerCard({ answer }: { answer: Answer }) { } } useEffect(() => setCopied(false), [answer]); + const advanced = useAdvanced(); + if (!advanced && !inCore(answer)) return ; return ( <> @@ -142,7 +162,9 @@ const fold = (text: string) => text.normalize('NFD').replace(/[\u0300-\u036f]/g, function Index({ onPick }: { onPick: (id: string) => void }) { const [query, setQuery] = useState(''); const [filter, setFilter] = useState('all'); - const entries = useMemo(() => allAnswers(), []); + const advanced = useAdvanced(); + const all = useMemo(() => allAnswers(), []); + const entries = advanced ? all : all.filter(({ answer }) => inCore(answer)); const words = fold(query).split(/\s+/).filter(Boolean); const shown = entries.filter(({ answer }) => { @@ -159,7 +181,7 @@ function Index({ onPick }: { onPick: (id: string) => void }) {

    Browse all {entries.length} models

    -

    Already know what you need? Search by name, or by the traditional test you know it as.

    +

    Already know what you need? Search by name, or by the traditional test you know it as.{!advanced && ' This list holds the models the course teaches; switch on Advanced topics under Settings for the rest.'}

    -
    + {advanced &&
    {FILTERS.map((option) => ( ))} -
    +
    }
    {sections.length === 0 &&

    No model matches. Try another word, or answer the questions above.

    } From 5355e08c1b9c92bbe1cc385c5b6dd0462f96e143 Mon Sep 17 00:00:00 2001 From: PeterValeria Date: Mon, 19 Oct 2026 10:28:26 +0000 Subject: [PATCH 7/8] Add a Research methods module and align lessons with the IRM course Adds five research methods lessons before Module 0, following the BMS psychology course Introduction to Research Methodology and Van der Kolk's readers: research questions, units and measurement levels, sampling, causality with crosstabs and the elaboration models, and research designs, with four checked exercises. Aligns existing lessons with the R the course teaches: crosstabs with the independent variable in the columns and column percentages (09-2 and its exercise), Tukey quartiles and whiskers by hand (04-2), the mode and tabyl frequency tables with valid percentages (03-1), scale(), reversing and a composite index plus the empirical rule (05-2), rm(list = ls()) (01-1) and foreign::read.spss (02-4). Updates credits, README and counts. --- README.md | 32 ++- docs/readme/hero.svg | 6 +- index.html | 4 +- public/Rplots.pdf | Bin 0 -> 4780 bytes src/components/Credits.tsx | 19 +- src/components/Sidebar.tsx | 4 +- src/components/SiteGuide.test.tsx | 2 +- src/components/SiteGuide.tsx | 4 +- src/content/content.test.ts | 8 +- src/content/exercises/index.ts | 3 +- src/content/exercises/module-09.ts | 28 +-- src/content/exercises/module-rm.ts | 208 ++++++++++++++++++ src/content/guide.ts | 8 +- src/content/guideIndex.ts | 5 + .../lessons/01-1-objects-and-scripts.mdx | 11 + .../lessons/02-4-missing-and-recoding.mdx | 8 + src/content/lessons/03-1-summaries.mdx | 27 ++- .../lessons/04-2-boxplots-and-facets.mdx | 34 ++- src/content/lessons/05-2-z-scores.mdx | 54 ++++- src/content/lessons/09-2-tables-of-counts.mdx | 58 ++--- .../lessons/rm-1-research-questions.mdx | 82 +++++++ .../lessons/rm-2-units-and-variables.mdx | 97 ++++++++ src/content/lessons/rm-3-sampling.mdx | 108 +++++++++ .../rm-4-causality-and-third-variables.mdx | 146 ++++++++++++ src/content/lessons/rm-5-research-designs.mdx | 107 +++++++++ src/content/manifest.ts | 57 ++++- src/content/parts.ts | 6 +- src/content/reading.test.ts | 5 +- src/content/reading.ts | 17 +- src/pages/Home.tsx | 2 +- src/pages/Lesson.tsx | 10 +- src/pages/Review.tsx | 2 +- 32 files changed, 1073 insertions(+), 89 deletions(-) create mode 100644 public/Rplots.pdf create mode 100644 src/content/exercises/module-rm.ts create mode 100644 src/content/lessons/rm-1-research-questions.mdx create mode 100644 src/content/lessons/rm-2-units-and-variables.mdx create mode 100644 src/content/lessons/rm-3-sampling.mdx create mode 100644 src/content/lessons/rm-4-causality-and-third-variables.mdx create mode 100644 src/content/lessons/rm-5-research-designs.mdx diff --git a/README.md b/README.md index 3d1572e..72fff32 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

    - StatLab: learn statistics by doing it in R, right in your browser. 18 modules, 65 lessons, 90 exercises, 0 installs. + StatLab: learn statistics by doing it in R, right in your browser. 19 modules, 70 lessons, 94 exercises, 0 installs.

    @@ -49,7 +49,7 @@

    โœ… Answers checked by R

    - 90 exercises are marked by R itself. A wrong answer gets feedback on the specific mistake, not just a red cross. + 94 exercises are marked by R itself. A wrong answer gets feedback on the specific mistake, not just a red cross.

    ๐Ÿง‘โ€๐Ÿ”ฌ An avatar that coaches

    @@ -165,8 +165,8 @@ live in the browser, and export to a file that imports on any other computer. ## The course -**18 modules ยท 65 lessons ยท 90 checked exercises ยท 6 interactive simulations.** -From "what is a file path?" to mixed models, logistic regression, mediation and +**19 modules ยท 70 lessons ยท 94 checked exercises ยท 6 interactive simulations.** +From research questions and "what is a file path?" to mixed models, logistic regression, mediation and Bayes. Statistics is taught the way the course team's own R workshops teach it: in tidyverse style and through the linear model. `lm`, `lmer` and `glm` do the work, and the t-test, ANOVA and chi-square appear as those same models under @@ -174,6 +174,8 @@ their traditional names. | | Module | What it covers | Simulation | |---|---|---|---| +| **Research methods** | | | | +| RM | Research methods | Empirical research questions, units and variables, D-NOIR levels of measurement, sampling, crosstabs with epsilon, causality and the elaboration models, research designs and threats to validity | | | **Foundations** | | | | | 0 | Before you start | RStudio Projects, files, folders and paths, and what R's symbols mean, with a cheat sheet | | | 1 | First steps in R | Objects, functions, help, packages and `library()` | | @@ -197,8 +199,9 @@ their traditional names. | 16 | Bayesian statistics | Prior, likelihood and posterior, credible intervals, Bayes factors, Bayesian regression | | | 17 | Mediation and reports | Indirect effects with bootstrap intervals, reproducible reports with Quarto | | -Modules 0 to 15 follow van den Berg's book and the R taught in the BMS -courses. Everything beyond them, the Advanced modules and a few sections +The Research methods module follows the BMS psychology course +*Introduction to Research Methodology* and H. van der Kolk's readers. Modules 0 +to 15 follow van den Berg's book and the R taught in the BMS courses. Everything beyond them, the Advanced modules and a few sections inside core lessons (Fisher's exact test, robust and bootstrap intervals, nested random effects) and the model chooser's answers the course does not teach, appears only after switching on **Advanced topics** under Settings. @@ -212,10 +215,10 @@ that every chapter is used. | Ch | Topic (links to the chapter) | StatLab lessons | |---|---|---| -| 1 | [Variables, variation and co-variation](https://019c2310-d1f3-c202-b65d-2af52db09a6c.share.connect.posit.cloud/intro.html) | 2-1, 2-3, 3-1 to 3-3, 4-1 to 4-3, 5-1 to 5-3, 9-2 | -| 2 | [Inference about a mean](https://019c2310-d1f3-c202-b65d-2af52db09a6c.share.connect.posit.cloud/mean.html) | 6-1 to 8-4 | +| 1 | [Variables, variation and co-variation](https://019c2310-d1f3-c202-b65d-2af52db09a6c.share.connect.posit.cloud/intro.html) | RM-2, 2-1, 2-3, 3-1 to 3-3, 4-1 to 4-3, 5-1 to 5-3, 9-2 | +| 2 | [Inference about a mean](https://019c2310-d1f3-c202-b65d-2af52db09a6c.share.connect.posit.cloud/mean.html) | RM-3, 6-1 to 8-4 | | 3 | [Inference about a proportion](https://019c2310-d1f3-c202-b65d-2af52db09a6c.share.connect.posit.cloud/prop.html) | 9-1 | -| 4 | [Simple and multiple regression](https://019c2310-d1f3-c202-b65d-2af52db09a6c.share.connect.posit.cloud/simple.html) | 10-1, 10-2, 11-1, 11-2 | +| 4 | [Simple and multiple regression](https://019c2310-d1f3-c202-b65d-2af52db09a6c.share.connect.posit.cloud/simple.html) | RM-4, 10-1, 10-2, 11-1, 11-2 | | 5 | [Inference for linear models](https://019c2310-d1f3-c202-b65d-2af52db09a6c.share.connect.posit.cloud/inf-lm.html) | 10-3, 11-3 | | 6 | [Categorical predictors](https://019c2310-d1f3-c202-b65d-2af52db09a6c.share.connect.posit.cloud/categorical.html) | 12-1, 12-2 | | 7 | [Assumptions](https://019c2310-d1f3-c202-b65d-2af52db09a6c.share.connect.posit.cloud/assumptions.html) | 11-4 | @@ -237,6 +240,12 @@ RStudio) goes with Modules 0 to 2, 2 (handling data files) with Module 2, 5 (multivariate analysis) with Modules 11, 14 and 15, and 6 (psychometrics) with Lessons 3-4 and 17-2. +The research methods lessons point to sections of Van der Kolk's *A short guide +to asking research questions, to hypothesis construction and to basic data +analysis using crosstabs*: 1 and 2 (research questions) with RM-1, 3 and 4 +(units, variables and data matrices) with RM-2, 3 and 5 (crosstabs and +elaboration) with RM-4, and 1 (designs) with RM-5. + ### How a lesson works Every lesson is built from the same few blocks, so learning is active from the @@ -290,6 +299,11 @@ BMS) and the [Intro R](https://jsteinrucke-intro-r.share.connect.posit.cloud/) course. For help with your own research, ask the [BMS Methodology Shop](https://www.utwente.nl/en/bms/m-store/). +The research methods lessons follow *Introduction to Research Methodology* +(BMS psychology bachelor, Module 1) and H. van der Kolk's readers *A short +guide to asking research questions, to hypothesis construction and to basic +data analysis using crosstabs* and *Decision making and empirical research*. + RStudio is a trademark of Posit Software, PBC. StatLab is not affiliated with or endorsed by Posit. diff --git a/docs/readme/hero.svg b/docs/readme/hero.svg index e469b4e..9c592da 100644 --- a/docs/readme/hero.svg +++ b/docs/readme/hero.svg @@ -50,9 +50,9 @@ - 18modules - 64lessons - 89exercises + 19modules + 70lessons + 94exercises 0installs diff --git a/index.html b/index.html index c827370..0920aa2 100644 --- a/index.html +++ b/index.html @@ -5,12 +5,12 @@ StatLab: data analysis with R - + - + diff --git a/public/Rplots.pdf b/public/Rplots.pdf new file mode 100644 index 0000000000000000000000000000000000000000..476fde12d61225149b0fcfd822fd22db0e9445a1 GIT binary patch literal 4780 zcmZ`-c{r5o`xhaj?2?F=ko}z*V_&k2B}?{w$utI&F*D6l$i9c1q-YVSl-|xMy_mAiE+}m?s*ZsNf`i{fG$LV86f6OcL{Tt+EK&;&g{#4!FijW~j)1AkK|m{l&;NVpfTiH* zT6Tab2}ShBqRD_Yl|rOa=xR13f)5pqAu(@(J0YD=Z1%)H{0iX@a4?_l2=mK`YKMLxM8H$%M>3$XqYm0( z$OI}0O}B^mAA1aepXso~;Qc86fCd5%fF@WRh5i801V_&e5`!lAU>ISODfE;DLV|Lu zovf@!pgc{p&g(8*mb&6+J}4wQ8OqyrnCo5^bJOFx!70yq>h+27Y^mduce{;mBxvM3 zwDjGY36_~lJ=74eR`soJ>dTpx_uEn0FV4otJr)U`fdJU zqks3I1?p(w8=;Ye(nhMqM|k6?r`l4X&xA&lRQn#`JY<;X>VAo9&9i!qS0k>k=SEE3 zL2zb#n7I`kw>Q_*Tc4CuiQJYH%FL38gRDv~-;#N6x@Dg~WF%6++;f=6$)7ES)i`$0 zN!Fkwnr~=D^Pog*G~B>U@U7v&7De1`2iN!M9^R}aGts;40w3;RkV|r_!bE-*H%VvE;+eI=K|i9j?FZctcfJ zaqMN!)NNYbOoEElD*UELKseIk59x>jpYuZBgUiK|&1+^%9ojUWam%~8P77ICv4}ao z<6N8*eu0UoaXqop>LsVJMLfAj^(ZojbQAQug74&>+2gTgyqVU0?K`u>yux5!jN4K9 z`X>b!%QJ;G&&77UjmIO~ z`}BvKmftRfhK($3UmK-~dC;Ziry~0gI~Nn61vDn(;mI-?ukuMG%9h| zvZvM6=-Gbqt95Hs52sb!xmxVFHaaV6d&t?0)V6Tm^^dj>uQ-G_Og3D`#ym3Cj#>op zI1-f6ElE!b``pe&9>+`-)|NuEE?r1x=AMbIN)`FzuwE9wvevO`Pk~UVIAQ8aS!+X* z$){&&*uWEKGUbLJ=hXM!xpO-VP)uUL=A$LS(BF%=-k<7=O}2MNSz+5LEScR zf8LxNy1F^+wvGSNzVe~7psFeG{6k<)vG=>;uI;s64&>n5mgG3yV3&xNhvZskJoyLl zbTGMP=t@+D^NP?${OKS%5ByzIe=`E3;QuPfpN#Mq5r7t0A2Q$$FbKd7@T3z3fl4oU z7yw#fe6T1(LJ;5%rQfMS5scsSU!1|H`JXjTrzW&Lh61>QbaJG7zywh|A)pDJF901K z&;$;^7)Ev&2Q&_%nA%eq+^D0&Fh_TZBarNgDD(~jlI=_l0o9-4|8NuoV)%0hj~N*m zqR1E@K#d{g`IGe+r=M>$VDJo{;RHlt&S23PhEec1oyowz`3SD6srG*o5+kNxgp`+Y z*1>*ge*&#Zy~*YhPtxV?0Dn7XvwclwY0C024j%JI7nvm0kPbwoZCdFbxa>q^u~FJR z$)vblw)Ylx3nTka69q&vj`>swk3S4v?z}oKz7`%(&_o;cTMip&V7j;!!}`S1g(Grr zk&D2)*|x+B$$sAKyY_x$V!<&@5UZ*T_ph5UXETQmrr@&q*M*uIxPWh)@_k2B9y9$Y zxLofl31hO!N}2te^zu4{hE#8)?9(d z>Iip`yCyLor$>%VUC`ufPD%3?7Mk6&UJq}ULB~*z#p^#KOy3g@J+8(iduc>@hHUSH zQ$k;og-0t*1v5>Uy*Tl)7j$_=w4v;%qLZrC@kY)AAcd8KlvSvW;L7K_6o|S ze&O&WY%p8cRrDQ+$(Od}Di9nfd|y&~P(xD#8mJW5EZZO9xpno@+L;Wm*oY-zr1*O4 zSLg8TeN1;tG1WsG34vHaCeMXkHjUc}o!%f*(*0&CSJ-K>hEOlNr<00)3b#f^JS=t< z1jvDN%Nqq;5k@CMX?D${%YUMRR?;;tQD8=1>UiIfQsmR;U6aIbduIv~*2USsQ^of4 zZb>yKf|rev9i^6lWk$98bN)ouY{Pi{^@5yr?$+-mL-iTw->&m^XSgt5mpG^&F2*D) z&kJyz^HJzt4&!`g%JQuV@_(G}iDZm>}cc@Z&c z!~>Kl^aMhKT{)`5d-upLJ1AFvq})fyD}A2WxaT~Ab_o=~=(*_qIG2ke(=4n;yf&hj z_edKVnlTINFBIC{5G{|NFY=vppOd8=Y+%}FR9k54&$l5M!hRUaQTo{JE-#L?%G+9F zPim~t;};*{8sf^)wU4e4SweZ1Vy#;#7;(~FL*D{KniqwkBZP{(|Zs_$p_RF4v zeBre+3AonT(>2FLQPdL7OI9{!?go;psr z5swg^iTriK5BbJ;mQ%RfRz7xo!mOKwa)<9Znpz+s%b&t=C)UhR9;H-tsoO-uqRt}7 zK#K26;_;`%Yms&FF2{6`I@Y4n&C<4JzEWy(QBo3t5{pOjBu6EifbmUdLU|YY-LfAAoD}+HDe@k+L8Xb#2!~Y%kK0+5E=57e(`KJ;@_D^5J74kC z*6q4OF~#A1eD4Fez}3qifxILM(=EsWH)8~WYG?><<=Pc`hIG^ z$-L{l#y%sJQr2!e?DYo(|Ck4If=Nkv<`2yC%`dz>Ehu-WNTi2l?)@r!1GPcj&~{XE zw03NsmwB!BTA{r1^~{an8=qdA_rHB@(m!@nr9Z5{ufO)@*_-2i`q(UNY{2xX4?P;U zK2f*=Q#(YLaxZBn%#IAis&*m{Bb@!0`b4{P2YLrS_r2^b@6{^);&AoOVo_dslhJ1E z*Agzrb}Q4uDx=wwE_6JkH9f8ChR-Ujl%h)Arb;(_X^3f133(P`8xj|?7_zp?v7xvv zwS8!#X~UjPi%o)!m92uUcHgIX#a4k<&-m^5$_ptMPNt|GpbG24om9A>UFvw%<^V&r z)9S9iz22lw0rX3_mC9&f$k1N3Ds>CkEtm*Aq4Ury)|tl61f}l%$M+j1vn7jI^7W-> zx8g9VZoNt|N}NiKiUqF1E^Y3CRr9qQ{(*!%{HT9ed;Cd$e|!Hv^vbP@Y^66f&%VC( z`0O!We{1#(Ee!96Z?7FjpY6*(C4REyar2w+F)_t4&x<-yy*-a$J@*Dau_Zr)paXsL z&Khb5YgU}mnpqtfdhGfkbnu&yP^m?ZMRG@R@UxZmm4mYv>gVf89t)n=@q25LwM}@t zvz}*D&&6!AaEEc{o2IwNmfiF#pEaQQc@20;R&rIo!TEhP|0+j#I!tSPdagTRlK)Tp zMe?{aO?{~L_1TxSF+$w9T*QNji$BzUi0pFPH4-@!c~igr(Uqt$gOWm4gVzS!(Vfx8 zk98%8X5P=gUTj?1H_JJDHZHn5vU{;;?MX^&MqHS9DhD@bB}c>o6u&J`PfKKj_lxKZ z)=J4H$qvb7WH$2HwUA=LV*YDai%+x%wO?*GLsy)1e&&_bm&pH=+K!$op1L|&CbXeA zQcu)*tXHJ*ZbNd)I5=5Zalj?Fd3~L)O*&WFl&|MJ?7Y(XWzC{owyxFQ$m`V>Wp%hE z^7w=E@OW^D|as`8wH?`F3fa9Ukp>;LI=EC>yxme4Nv2xZtat zu1u3IOJ-WuX$LdjQjSe{waN-Sq3TstQdLPuZswwgnlyw@6=O7%e7~^`F+}T+e2Tp9 z1<#qvuuor+QP)~C%O~YS3EuTXpRGEMd=6V_@rrVL?Aha9=zh1xn{s{j<%VN-`Sn{N z&I^^wZo19S2%S;YvDMgs2-ye9iePBHvahFgnp02CwR>N}7u=HtlZ@Nc)#`9Jh7;e) z4rs6~2q5 zZ{IWqH&%Z5G}z}<=IHFUhOhKq?hI+!=wd65PYTNl)7%tOGg1E<(Yeu3f2{cCgZHiB z@50|lC9;HdrgnBJ=o@{8yG1Cg%@ku!kV+7XxVpahrPUze z+#h#SAB6d>dj_&s7nktQdyHwmzS1kbn7SaQcQEKdZTt1IsdF96YvO&nPGpZigN8Vs zoWI6jaD1j9SMLgS{{8#uK6H~s$a3y^U2+BZknj=LJ+v|#O3Qn{_Tgyo*q5z)oLxex zUOt{58m5Ui$&bPdr`R{ewb%}^X(xy#G=SQkOTzi;p_56QSNfcrD)Srn6Bm1*Z>7Ck zuzf=qGpmzmdeQi8&S|4!ww8>ijkuD9!fL1Udz+FrbJj${5!?4X=K)>4d0nlK-j8Fn z(IfYc9L4-t4SzT%@eS1$+l5>?JZ#!76LthV>a`xUb!;F#yR7Y~!VizlSJOjhTY|RW zP(f|g?;fjLeU(>F+-Mp6(Yu6MrVOrbI}2C(dSyPejUZ1bEd>5(%sR`jQonp}S>b3_ z_}sSxtDlz2QmhNYS=M4#Hh*iiHGj3CkiUm-V?2G(#p3DHH=}L+=j4pV8{o;Qo92PRWJ~Xr;-6pHSjc*K*8WJz7z-yfdG97^d$kAj0HeH66y@S$3>y3 zj8+$oC84Q-zBo(}08+3xA379>LX!x106a;;Fa+tEC^VWr#6v(IEZr3uO9nt?Jb~ha z@dZHAPLODN=a0jo{)PKf@qQ>0H4ulQQUH+PN5EqO=-yB}mdT9H-Sc1Dhn-&kXCwaC z`eA1h0PU$KDLdN;21p+*rmCu<3itxQFgSfD!}tLFFASzepM(AzgTbNn!Qy{04f+D(e=r2y(r=i` zZ}y;Y7=8Ql-?~t^=5H7R2LH_mLPg_Wu~0}TEDl4Wuap4L9vgz8zZU?H;e848>5P#N g(2hW$0E|`7PHxB)6p6Bv83aND4w05Nwl;zM2O(QA3jhEB literal 0 HcmV?d00001 diff --git a/src/components/Credits.tsx b/src/components/Credits.tsx index 60a4b5c..089ee4c 100644 --- a/src/components/Credits.tsx +++ b/src/components/Credits.tsx @@ -35,6 +35,16 @@ export const R_MATERIALS = { shop: { title: 'BMS Methodology Shop', href: 'https://www.utwente.nl/en/bms/m-store/' }, }; +/** The research methods course's own readers, which the RM lessons follow. */ +export const RM_MATERIALS = { + course: { title: 'Introduction to Research Methodology', where: 'BMS psychology bachelor, Module 1' }, + shortGuide: { + title: 'A short guide to asking research questions, to hypothesis construction and to basic data analysis using crosstabs', + author: 'Van der Kolk', + }, + decisionMaking: { title: 'Decision making and empirical research', author: 'Van der Kolk' }, +}; + export const PARTNERS = [ { key: 'utwente', name: 'University of Twente', href: 'https://www.utwente.nl/en/' }, { key: 'bmslab', name: 'The BMS Lab', href: 'https://bmslab.utwente.nl/' }, @@ -77,7 +87,7 @@ export default function Credits({ variant = 'full' }: Props) { {REFERENCE_BOOK.title} {' '} - by {REFERENCE_BOOK.author} (5th ed., University of Twente, 2021), licensed under a{' '} + by {REFERENCE_BOOK.author} (University of Twente; the online book is the 2024 edition), licensed under a{' '} Creative Commons BY-NC-SA licence @@ -95,6 +105,13 @@ export default function Credits({ variant = 'full' }: Props) { {R_MATERIALS.shop.title}.

    )} + {variant === 'full' && ( +

    + The research methods lessons follow {RM_MATERIALS.course.title} ({RM_MATERIALS.course.where}) + and H. van der Kolk's readers{' '} + {RM_MATERIALS.shortGuide.title} and {RM_MATERIALS.decisionMaking.title}. +

    + )} {variant === 'full' && (

    StatLab is licensed under{' '} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 45df88f..9726695 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -114,8 +114,8 @@ export default function Sidebar({ open = false }: Props) {

    diff --git a/src/components/SiteGuide.test.tsx b/src/components/SiteGuide.test.tsx index ff9a945..9ff63f7 100644 --- a/src/components/SiteGuide.test.tsx +++ b/src/components/SiteGuide.test.tsx @@ -19,7 +19,7 @@ test('the avatar helper suggests a lesson from a few words and goes there', asyn const button = screen.getByRole('button', { name: /Where to\?/ }); await user.click(button); expect(screen.getByRole('dialog', { name: 'Where do you want to go?' })).toBeTruthy(); - expect(screen.getByRole('link', { name: /Start here: Lesson 0-1/ })).toBeTruthy(); + expect(screen.getByRole('link', { name: /Start here: Lesson RM-1/ })).toBeTruthy(); await user.type(screen.getByLabelText('Type a topic or a question'), 'logistic regression'); await user.click(screen.getByRole('link', { name: /Lesson 15-2/ })); expect(screen.getByTestId('where').textContent).toBe('/lesson/15-2'); diff --git a/src/components/SiteGuide.tsx b/src/components/SiteGuide.tsx index 33af0ee..41c8892 100644 --- a/src/components/SiteGuide.tsx +++ b/src/components/SiteGuide.tsx @@ -1,7 +1,7 @@ import { useEffect, useId, useRef, useState } from 'react'; import { Link, useLocation } from 'react-router-dom'; import { guide } from '../content/guide'; -import { ALL_LESSONS, findLesson } from '../content/manifest'; +import { ALL_LESSONS, findLesson, lessonLabel } from '../content/manifest'; import { currentLook } from '../state/avatar'; import { getProgress, lastVisitedLesson } from '../state/progress'; import Avatar from './Avatar'; @@ -62,7 +62,7 @@ export default function SiteGuide() { {text.trim() === '' ? ( <> - {lastVisitedLesson() ? 'Carry on' : 'Start here'}: Lesson {last.id.replace(/^0/, '')} + {lastVisitedLesson() ? 'Carry on' : 'Start here'}: {lessonLabel(last)} {last.title}

    Or try:

    diff --git a/src/content/content.test.ts b/src/content/content.test.ts index bb3835f..f9ee1c7 100644 --- a/src/content/content.test.ts +++ b/src/content/content.test.ts @@ -174,7 +174,7 @@ describe('lesson content', () => { expect(numbers).toEqual([...numbers].sort((a, b) => a - b)); for (const module of PLANNED_MODULES) { expect(module.id, `${module.id} does not match number ${module.number}`).toBe( - `module-${String(module.number).padStart(2, '0')}`, + module.badge ? `module-${module.badge.toLowerCase()}` : `module-${String(module.number).padStart(2, '0')}`, ); } }); @@ -535,10 +535,10 @@ describe('Module 0', () => { '00-4-symbols-pick-and-pipe', ]; - test('its four lesson files exist and Module 0 opens the course', () => { + test('its four lesson files exist and Module 0 opens the numbered run, after research methods', () => { for (const file of lessons) expect(sources[`./lessons/${file}.mdx`], `missing ${file}`).toBeDefined(); - expect(MODULES[0].id).toBe('module-00'); - expect(ALL_LESSONS[0].id).toBe('00-1'); + expect(MODULES.map((module) => module.id).slice(0, 2)).toEqual(['module-rm', 'module-00']); + expect(ALL_LESSONS.find((lesson) => lesson.id.startsWith('0'))?.id).toBe('00-1'); }); test('Module 0 defines exactly the exercises the manifest lists, in order', () => { diff --git a/src/content/exercises/index.ts b/src/content/exercises/index.ts index 4d5eac3..1eca871 100644 --- a/src/content/exercises/index.ts +++ b/src/content/exercises/index.ts @@ -17,9 +17,10 @@ import { module14 } from './module-14'; import { module15 } from './module-15'; import { module16 } from './module-16'; import { module17 } from './module-17'; +import { moduleRm } from './module-rm'; export const ALL_EXERCISES: ExerciseDef[] = [ - ...module00, + ...moduleRm, ...module00, ...module01, ...module02, ...module03, ...module04, ...module05, ...module06, ...module07, ...module08, ...module09, ...module10, ...module11, ...module12, ...module13, ...module14, ...module15, ...module16, ...module17, diff --git a/src/content/exercises/module-09.ts b/src/content/exercises/module-09.ts index 15cf197..cb06e35 100644 --- a/src/content/exercises/module-09.ts +++ b/src/content/exercises/module-09.ts @@ -65,20 +65,20 @@ export const module09: ExerciseDef[] = [ { id: 'm9-2-a', prompt: - 'Which department loses the most people? Cross department with left_company, then turn the counts into row proportions, so that each department adds up to 1 and the second column is its leaving rate. Store the result in leave_rates.', - starterCode: `${LOAD}\n\ncounts <- table(d$department, d$left_company)\ncounts\n\nleave_rates <- `, - solution: `${LOAD}\ncounts <- table(d$department, d$left_company)\nleave_rates <- prop.table(counts, margin = 1)`, + 'Which department loses the most people? Cross left_company (rows) with department (columns), then turn the counts into column proportions, so that each department adds up to 1 and the second row is its leaving rate. Store the result in leave_rates.', + starterCode: `${LOAD}\n\ncounts <- table(d$left_company, d$department)\ncounts\n\nleave_rates <- `, + solution: `${LOAD}\ncounts <- table(d$left_company, d$department)\nleave_rates <- prop.table(counts, margin = 2)`, wrongAnswers: [ // Every cell as a share of all 480 people. - `${LOAD}\ncounts <- table(d$department, d$left_company)\nleave_rates <- prop.table(counts)`, - // Column proportions: which departments the leavers came from. - `${LOAD}\ncounts <- table(d$department, d$left_company)\nleave_rates <- prop.table(counts, margin = 2)`, + `${LOAD}\ncounts <- table(d$left_company, d$department)\nleave_rates <- prop.table(counts)`, + // Row proportions: which departments the leavers came from. + `${LOAD}\ncounts <- table(d$left_company, d$department)\nleave_rates <- prop.table(counts, margin = 1)`, // The counts, never turned into proportions. - `${LOAD}\nleave_rates <- table(d$department, d$left_company)`, + `${LOAD}\nleave_rates <- table(d$left_company, d$department)`, ], alternateSolutions: [ - // Dividing each row by its total. - `${LOAD}\ncounts <- table(d$department, d$left_company)\nleave_rates <- counts / rowSums(counts)`, + // The course's own way, tabyl() with column percentages. + `${LOAD}\nlibrary(dplyr)\nlibrary(janitor)\nleave_rates <- d %>% tabyl(left_company, department) %>% adorn_percentages("col")`, // The leaving rate directly, as the mean of a 0-1 column per department. `${LOAD}\nleave_rates <- aggregate(left_company ~ department, data = d, FUN = mean)`, ], @@ -99,11 +99,11 @@ export const module09: ExerciseDef[] = [ top <- levels(d$department)[which.max(rates)] list(pass = TRUE, message = paste0("Correct. ", top, " loses ", round(100 * max(rates)), "% of its people and the lowest department ", round(100 * min(rates)), "%. Whether a gap that size could be chance is the next lesson's question.")) } else if (any(values > 1, na.rm = TRUE)) { - list(pass = FALSE, message = "Those are still counts. Departments differ in size, so compare proportions: prop.table(counts, margin = 1).") + list(pass = FALSE, message = "Those are still counts. Departments differ in size, so compare proportions: prop.table(counts, margin = 2).") } else if (found(by_column)) { - list(pass = FALSE, message = "Those are column proportions: of everyone who left, which department they came from. The question is about each department's own rate, so divide by the row totals with margin = 1.") + list(pass = FALSE, message = "Those are row proportions: of everyone who left, which department they came from. The question is about each department's own rate, so divide by the column totals, the department sizes, with margin = 2.") } else if (abs(sum(values, na.rm = TRUE) - 1) < 1e-6) { - list(pass = FALSE, message = "Every cell is a share of all 480 employees, so the whole table adds up to 1. Give prop.table() margin = 1 so that each department adds up to 1 instead.") + list(pass = FALSE, message = "Every cell is a share of all 480 employees, so the whole table adds up to 1. Give prop.table() margin = 2 so that each department adds up to 1 instead.") } else { list(pass = FALSE, message = paste0("I expected the four leaving rates, from ", round(min(rates), 3), " to ", round(max(rates), 3), ", and could not find them in leave_rates.")) } @@ -111,8 +111,8 @@ export const module09: ExerciseDef[] = [ `, hints: [ 'prop.table() turns a table of counts into proportions. Its margin argument says what should add up to 1.', - 'margin = 1 means rows: each department then adds up to 1.', - 'leave_rates <- prop.table(counts, margin = 1)', + 'margin = 2 means columns: each department then adds up to 1.', + 'leave_rates <- prop.table(counts, margin = 2)', ], }, { diff --git a/src/content/exercises/module-rm.ts b/src/content/exercises/module-rm.ts new file mode 100644 index 0000000..0385c04 --- /dev/null +++ b/src/content/exercises/module-rm.ts @@ -0,0 +1,208 @@ +import type { ExerciseDef } from '../../r/checker'; + +const POPULATION = 'library(dplyr)\npopulation <- read.csv("data/wellbeing-population.csv", stringsAsFactors = TRUE)'; + +const REGIONS = `library(dplyr) +library(tidyr) +library(janitor) +reduced <- data.frame( + money = c("high", "high", "high", "high", "low", "low", "low", "low"), + support = c("high", "high", "low", "low", "high", "high", "low", "low"), + development = c("low", "high", "low", "high", "low", "high", "low", "high"), + n = c(10, 60, 60, 80, 2, 13, 95, 40) +) +regions <- reduced %>% uncount(n)`; + +const WORKPLACE = 'd <- read.csv("data/workplace.csv", stringsAsFactors = TRUE)'; + +export const moduleRm: ExerciseDef[] = [ + { + id: 'mrm-2-a', + prompt: + 'Height is a ratio variable, so a mean makes sense. What is the mean height of the Star Wars characters? A few heights are missing. Store the mean in mean_height.', + starterCode: 'library(dplyr)\n\nmean_height <- ', + solution: 'library(dplyr)\nmean_height <- mean(starwars$height, na.rm = TRUE)', + wrongAnswers: [ + // The missing heights make the mean NA. + 'library(dplyr)\nmean_height <- mean(starwars$height)', + // The median, not the mean. + 'library(dplyr)\nmean_height <- median(starwars$height, na.rm = TRUE)', + // The wrong column. + 'library(dplyr)\nmean_height <- mean(starwars$mass, na.rm = TRUE)', + ], + alternateSolutions: [ + 'library(dplyr)\nmean_height <- starwars %>% summarise(mean_height = mean(height, na.rm = TRUE))', + 'library(dplyr)\nmean_height <- mean(na.omit(starwars$height))', + ], + check: ` + target <- mean(dplyr::starwars$height, na.rm = TRUE) + if (!has_answer("mean_height")) { + list(pass = FALSE, message = "I could not find an object called mean_height.") + } else { + got <- answer("mean_height") + value <- if (is.data.frame(got)) unlist(numeric_columns(got)) else suppressWarnings(as.numeric(got)) + if (length(value) != 1L) { + list(pass = FALSE, message = "mean_height should be one number.") + } else if (is.na(value)) { + list(pass = FALSE, message = "mean_height is NA, because some heights are missing and R will not guess them. Add na.rm = TRUE to mean() to leave them out.") + } else if (abs(value - median(dplyr::starwars$height, na.rm = TRUE)) < 1e-6) { + list(pass = FALSE, message = "That is the median height. The question asks for the mean.") + } else if (abs(value - target) > 0.01) { + list(pass = FALSE, message = paste0("mean_height is ", round(value, 1), ", but the mean of the height column, without the missing values, is ", round(target, 1), ". Check that you used height.")) + } else { + list(pass = TRUE, message = paste0("Correct: ", round(target, 1), " cm, over the ", sum(!is.na(dplyr::starwars$height)), " characters whose height is known. Saying how many values a mean rests on is part of reporting it.")) + } + } + `, + hints: [ + 'starwars$height picks the height column.', + 'mean() returns NA when any value is missing, unless you add na.rm = TRUE.', + 'mean_height <- mean(starwars$height, na.rm = TRUE)', + ], + }, + { + id: 'mrm-3-a', + prompt: + 'Draw a stratified sample: 50 Business and 50 Psychology students, each drawn at random within their programme. Store the 100 rows in stratified.', + starterCode: `${POPULATION}\nset.seed(1)\n\nstratified <- `, + solution: `${POPULATION}\nset.seed(1)\nstratified <- population %>% group_by(programme) %>% slice_sample(n = 50) %>% ungroup()`, + wrongAnswers: [ + // A simple random sample: the programme counts are left to chance. + `${POPULATION}\nset.seed(1)\nstratified <- population %>% slice_sample(n = 50)`, + // The first 50 per programme: fixed numbers, but not drawn at random. + `${POPULATION}\nstratified <- population %>% group_by(programme) %>% slice_head(n = 50) %>% ungroup()`, + // One programme only. + `${POPULATION}\nset.seed(1)\nstratified <- population %>% filter(programme == "Business") %>% slice_sample(n = 100)`, + ], + alternateSolutions: [ + `${POPULATION}\nset.seed(1)\nstratified <- population %>% slice_sample(n = 50, by = programme)`, + `${POPULATION}\nset.seed(1)\nstratified <- do.call(rbind, lapply(split(population, population$programme), function(g) g[sample(nrow(g), 50), ]))`, + ], + check: ` + population <- read.csv("data/wellbeing-population.csv", stringsAsFactors = TRUE) + first <- unlist(lapply(split(population$id, population$programme), head, 50)) + if (!has_answer("stratified")) { + list(pass = FALSE, message = "I could not find an object called stratified.") + } else { + got <- as.data.frame(answer("stratified")) + if (!all(c("id", "programme") %in% names(got))) { + list(pass = FALSE, message = "stratified should hold rows of the population, with all its columns, including id and programme.") + } else if (anyDuplicated(got$id) > 0) { + list(pass = FALSE, message = "Some students appear more than once. A sample draws each person at most once, so leave out replace = TRUE.") + } else if (nrow(got) != 100L) { + list(pass = FALSE, message = paste0("stratified has ", nrow(got), " rows; it should have 100, 50 per programme. Group by programme before you draw, so n counts within each group.")) + } else if (any(table(factor(got$programme, levels = levels(population$programme))) != 50L)) { + list(pass = FALSE, message = "The programmes are not 50 each. Group by programme first, so slice_sample() draws within each programme.") + } else if (setequal(got$id, first)) { + list(pass = FALSE, message = "Those are the first 50 rows of each programme, not a random draw. Use slice_sample() instead of slice_head().") + } else { + list(pass = TRUE, message = "Correct: 50 per programme, each drawn at random within its group. That makes it a stratified sample, and every student in the frame had a known chance of being selected.") + } + } + `, + hints: [ + 'group_by(programme) makes the next step work within each programme.', + 'slice_sample(n = 50) then draws 50 rows from each group.', + 'stratified <- population %>% group_by(programme) %>% slice_sample(n = 50) %>% ungroup()', + ], + }, + { + id: 'mrm-4-a', + prompt: + 'Is economic development related to support for the EU? Make the crosstab with support in the rows and development in the columns, with column percentages, and store it in ty.', + starterCode: `${REGIONS}\n\nty <- `, + solution: `${REGIONS}\nty <- regions %>% tabyl(support, development) %>% adorn_percentages("col")`, + wrongAnswers: [ + // Row percentages: of each support level, which development it came from. + `${REGIONS}\nty <- regions %>% tabyl(support, development) %>% adorn_percentages("row")`, + // The money table from the lesson, not the development one. + `${REGIONS}\nty <- regions %>% tabyl(support, money) %>% adorn_percentages("col")`, + // Counts, never turned into percentages. + `${REGIONS}\nty <- regions %>% tabyl(support, development)`, + ], + alternateSolutions: [ + `${REGIONS}\nty <- regions %>% tabyl(support, development) %>% adorn_percentages("col") %>% adorn_pct_formatting(digits = 1)`, + `${REGIONS}\nty <- prop.table(table(regions$support, regions$development), margin = 2)`, + ], + check: ` + values_of <- function(x) { + if (!is.data.frame(x)) return(suppressWarnings(as.numeric(unclass(x)))) + out <- c() + for (column in x) { + if (is.numeric(column)) out <- c(out, column) + else if (is.character(column) && any(grepl("%", column))) out <- c(out, suppressWarnings(as.numeric(sub("%", "", column))) / 100) + } + out + } + if (!has_answer("ty")) { + list(pass = FALSE, message = "I could not find an object called ty.") + } else { + values <- values_of(answer("ty")) + values <- values[!is.na(values)] + # Proportions or percentages both count. + found <- function(targets, tol = 0.006) all(vapply(targets, function(r) any(abs(values - r) < tol | abs(values / 100 - r) < tol), logical(1))) + if (length(values) == 0L) { + list(pass = FALSE, message = "ty should hold the percentages of the table.") + } else if (found(c(12 / 167, 73 / 193))) { + list(pass = TRUE, message = "Correct: 7% of low-development regions show high support, against 38% of high-development ones, a difference of 31 points. Development is related to support, and the lesson showed it is related to EU money too, which is why it is worth checking as a test variable.") + } else if (found(c(12 / 85, 155 / 275))) { + list(pass = FALSE, message = "Those are row percentages: of the regions with high support, how many are developed. Development is the independent variable here, so it goes in the columns and the percentages add up within columns: adorn_percentages(\\"col\\").") + } else if (found(c(15 / 150, 70 / 210))) { + list(pass = FALSE, message = "That is the money table from the lesson. Put development in the columns instead: tabyl(support, development).") + } else if (any(values > 1)) { + list(pass = FALSE, message = "Those are still counts. Low- and high-development groups differ in size, so turn them into column percentages with adorn_percentages(\\"col\\").") + } else { + list(pass = FALSE, message = "I could not find the column percentages of support by development in ty. Use tabyl(support, development) and then adorn_percentages(\\"col\\").") + } + } + `, + hints: [ + 'tabyl() takes the row variable first and the column variable second.', + 'adorn_percentages("col") makes each column add up to 100%.', + 'ty <- regions %>% tabyl(support, development) %>% adorn_percentages("col")', + ], + }, + { + id: 'mrm-5-a', + prompt: + 'Assign the 480 employees at random to two equal groups of 240: "treatment" and "control". Store the labels, one per employee, in group.', + starterCode: `${WORKPLACE}\nset.seed(5)\n\ngroup <- `, + solution: `${WORKPLACE}\nset.seed(5)\ngroup <- sample(rep(c("treatment", "control"), each = 240))`, + wrongAnswers: [ + // Not shuffled: the first 240 employees all get the treatment. + `${WORKPLACE}\ngroup <- rep(c("treatment", "control"), each = 240)`, + // A coin flip per person: random, but the groups are not equal in size. + `${WORKPLACE}\nset.seed(5)\ngroup <- sample(c("treatment", "control"), 480, replace = TRUE)`, + // One group too small. + `${WORKPLACE}\nset.seed(5)\ngroup <- sample(rep(c("treatment", "control"), each = 120))`, + ], + alternateSolutions: [ + `${WORKPLACE}\nset.seed(5)\ngroup <- ifelse(sample(nrow(d)) <= 240, "treatment", "control")`, + `${WORKPLACE}\nset.seed(5)\ngroup <- sample(rep(c("control", "treatment"), times = nrow(d) / 2))`, + ], + check: ` + if (!has_answer("group")) { + list(pass = FALSE, message = "I could not find an object called group.") + } else { + got <- as.character(answer("group")) + counts <- table(factor(got, levels = c("treatment", "control"))) + if (length(got) != 480L) { + list(pass = FALSE, message = paste0("group has ", length(got), " labels; it needs one for each of the 480 employees.")) + } else if (!all(got %in% c("treatment", "control"))) { + list(pass = FALSE, message = "Use exactly the labels \\"treatment\\" and \\"control\\".") + } else if (any(counts != 240L)) { + list(pass = FALSE, message = paste0("The groups have ", counts[["treatment"]], " and ", counts[["control"]], " people. Shuffle a vector of exactly 240 of each label, rep(..., each = 240), instead of drawing a label per person.")) + } else if (all(got[1:240] == got[1]) || all(got[seq(1, 479, 2)] == got[1])) { + list(pass = FALSE, message = "The labels follow the order of the data, so they are not random. Shuffle them with sample().") + } else { + list(pass = TRUE, message = "Correct: 240 in each group, assigned by chance. Whatever differences remain between the groups before the intervention are now due to chance alone.") + } + } + `, + hints: [ + 'rep(c("treatment", "control"), each = 240) makes the 480 labels in order.', + 'sample() with one vector shuffles it.', + 'group <- sample(rep(c("treatment", "control"), each = 240))', + ], + }, +]; diff --git a/src/content/guide.ts b/src/content/guide.ts index 0678290..8d685d3 100644 --- a/src/content/guide.ts +++ b/src/content/guide.ts @@ -1,7 +1,7 @@ import { allAnswers } from '../pages/modelTree'; import { matchQuestion } from '../pages/questionMatcher'; import { LESSON_KEYWORDS, PAGE_ENTRIES } from './guideIndex'; -import { findLesson, MODULES } from './manifest'; +import { findLesson, lessonLabel, moduleLabel, MODULES } from './manifest'; /** A place on the site the helper can send a student to. */ export type GuideHit = { to: string; title: string; detail: string }; @@ -27,8 +27,8 @@ const ENTRIES: Entry[] = [ ...MODULES.flatMap((module) => module.lessons.map((lesson) => ({ to: `/lesson/${lesson.id}`, - title: `Lesson ${lesson.id.replace(/^0/, '')}: ${lesson.title}`, - detail: `Module ${module.number}: ${module.title}`, + title: `${lessonLabel(lesson)}: ${lesson.title}`, + detail: `${moduleLabel(module)}: ${module.title}`, titleWords: new Set(words(lesson.title)), words: new Set(words(`${module.title} ${LESSON_KEYWORDS[lesson.id] ?? ''}`)), })), @@ -66,7 +66,7 @@ export function guide(text: string): GuideHit[] { const answer = best && allAnswers().find((entry) => entry.answer.id === best.id)?.answer; if (answer) { const lesson = answer.lessonId ? findLesson(answer.lessonId) : undefined; - const teaching: GuideHit[] = lesson ? [{ to: `/lesson/${lesson.id}`, title: `Lesson ${lesson.id.replace(/^0/, '')}: ${lesson.title}`, detail: `Where the course teaches ${answer.model.toLowerCase()}` }] : []; + const teaching: GuideHit[] = lesson ? [{ to: `/lesson/${lesson.id}`, title: `${lessonLabel(lesson)}: ${lesson.title}`, detail: `Where the course teaches ${answer.model.toLowerCase()}` }] : []; const rest = hits.filter((hit) => !teaching.some((t) => t.to === hit.to)); return [{ to: `/which-model?model=${answer.id}`, title: `Likely analysis: ${answer.model}`, detail: 'Suggested from the words in your question. The model chooser explains it.' }, ...teaching, ...rest].slice(0, 4); } diff --git a/src/content/guideIndex.ts b/src/content/guideIndex.ts index 5c41d56..85f1d38 100644 --- a/src/content/guideIndex.ts +++ b/src/content/guideIndex.ts @@ -5,6 +5,11 @@ * student would ask. guide.test.ts checks every lesson has an entry. */ export const LESSON_KEYWORDS: Record = { + 'rm-1': 'research question empirical normative conceptual descriptive explanatory hypothesis wheel of science deduction induction confirmation bias evaluation ex ante ex post policy', + 'rm-2': 'unit of analysis observation data matrix measurement level nominal ordinal interval ratio dichotomous d-noir ecological fallacy operationalisation conceptualisation primary secondary data starwars glimpse', + 'rm-3': 'sample sampling population frame response rate random stratified cluster convenience snowball quota purposive bias error representative', + 'rm-4': 'causality cause third variable spurious crosstab contingency epsilon percentage difference elaboration confounder mediator moderator explanation interpretation specification replication tabyl', + 'rm-5': 'research design experiment random assignment control group pretest posttest time series validity internal external threats placebo double blind regression to the mean history maturation attrition', '00-1': 'start beginner begin new first never used r install rstudio program project panes setup', '00-2': 'file folder path directory working directory setwd save load where', '00-3': 'arrow assign equals symbol operator compare logical true false na missing quotes text', diff --git a/src/content/lessons/01-1-objects-and-scripts.mdx b/src/content/lessons/01-1-objects-and-scripts.mdx index 6bd1627..e298a6d 100644 --- a/src/content/lessons/01-1-objects-and-scripts.mdx +++ b/src/content/lessons/01-1-objects-and-scripts.mdx @@ -41,6 +41,17 @@ everything your session has stored so far. In RStudio it is listed in the Environment pane, top right. When R says "object not found", that list is the first place to look. +The R scripts in BMS courses often start by emptying it, so that objects left +over from an earlier session cannot sneak into a fresh run: + +```r +rm(list = ls()) +``` + +`ls()` lists every object, and `rm()` removes them. Do it at the top of a +script, never halfway, and keep in mind that it does not detach packages. +Restarting R does both. + ## Comments Everything after a `#` on a line is ignored by R. It is written for the human diff --git a/src/content/lessons/02-4-missing-and-recoding.mdx b/src/content/lessons/02-4-missing-and-recoding.mdx index deae4e8..cd23742 100644 --- a/src/content/lessons/02-4-missing-and-recoding.mdx +++ b/src/content/lessons/02-4-missing-and-recoding.mdx @@ -175,6 +175,14 @@ library(haven) survey <- read_sav("data/survey.sav") ``` +Some course scripts use the older foreign package instead, which comes with R +and turns labelled codes into factors straight away: + +```r +library(foreign) +survey <- read.spss("data/survey.sav", to.data.frame = TRUE) +``` + An SPSS file often stores its codebook inside the data: a label for each variable and for each value, such as 1 = "woman". `haven` keeps them as attributes, which `attr()` shows: diff --git a/src/content/lessons/03-1-summaries.mdx b/src/content/lessons/03-1-summaries.mdx index 636c2fe..017de9d 100644 --- a/src/content/lessons/03-1-summaries.mdx +++ b/src/content/lessons/03-1-summaries.mdx @@ -71,7 +71,7 @@ distribution is, and they disagree on purpose. with 480 people the two differ only in the second decimal. - The **interquartile range** is the width of the middle half of the company: the distance from the 25th to the 75th percentile. It ignores the tails entirely. -- The **range** โ€” largest minus smallest โ€” is built from exactly two people, so +- The **range**, largest minus smallest, is built from exactly two people, so it is the least stable of the three and the easiest to quote. Watch out for R's `range()`: it returns the two endpoints, not the distance @@ -83,6 +83,31 @@ max(employees$wellbeing) - min(employees$wellbeing)`} /> +## Which centre fits the level of measurement + +The mean needs numbers with equal steps, so it fits interval and ratio +variables. For an ordinal variable, use the **median**. For a nominal variable, +such as department, only the **mode** is left: the most frequent category. A +frequency table shows it straight away. BMS courses make one with `tabyl()` +from the janitor package. + +% + tabyl(department) %>% + adorn_totals("row") %>% + adorn_pct_formatting(digits = 1)`} /> + +Support is the mode. When a variable has missing values, `tabyl()` adds a +`valid_percent` column, the percentages among the people with an answer. The +Star Wars data that comes with dplyr has four characters with no recorded sex. + +% + tabyl(sex) %>% + adorn_pct_formatting(digits = 1)`} /> + +Report the valid percentages, and say how many values were missing. + +## Whiskers by hand + +The course computes a boxplot by hand with **Tukey's method**. Sort the values +and find the median. The first quartile Q1 is the median of the lower half and +Q3 the median of the upper half; with an odd number of values, the median +itself counts in both halves. Then IQR = Q3 โˆ’ Q1, and any value below +Q1 โˆ’ 1.5 ร— IQR or above Q3 + 1.5 ร— IQR is an outlier. The whiskers stop at the +most extreme values inside those limits. + +R's `fivenum()` gives Tukey's five numbers: minimum, Q1, median, Q3, maximum. + + + +Q1 is 5.5 and Q3 is 10.5, so IQR = 5 and the upper limit is 18: the value 20 is +an outlier, and the upper whisker stops at 11. + +`quantile()` and `IQR()`, which ggplot2 also uses, follow a different rule by +default. In large samples the difference is negligible; in small ones a +quartile can shift a little. + + + +Tukey gives 10 and 24.5, `quantile()` 11 and 24.25. On an exam done by hand, use +Tukey's method. + A boxplot hides the group sizes, so report them alongside it: % diff --git a/src/content/lessons/05-2-z-scores.mdx b/src/content/lessons/05-2-z-scores.mdx index 4136368..67db0ef 100644 --- a/src/content/lessons/05-2-z-scores.mdx +++ b/src/content/lessons/05-2-z-scores.mdx @@ -46,7 +46,7 @@ above the mean. The sleeper is the more unusual of the two, and no amount of sta ## Standardising a whole column A z-score is not only for single values. Standardise a whole column and you get -a variable with a mean of 0 and an SD of 1 โ€” the same distribution, relabelled. +a variable with a mean of 0 and an SD of 1: the same distribution, relabelled. % Compare that histogram with the one in the last lesson. It is the same shape. It has simply been slid so its centre is at 0 and squeezed so one SD is one unit. -Standardising never changes the shape of a distribution โ€” a point worth keeping, +Standardising never changes the shape of a distribution. That point is worth keeping, because students often expect it to "make the data normal". It does not. -## The 68โ€“95โ€“99.7 rule +R does the same in one step with `scale()`, which subtracts the mean and divides +by the SD. It returns a one-column matrix, so wrap it in `as.numeric()` when you +store it as a column. + +% + mutate(z_exam_scale = as.numeric(scale(exam_score))) + +# The same numbers both ways. +all.equal(standardised$z_exam, standardised$z_exam_scale)`} /> + +## Reversing, and combining into an index + +Because z-scores share a scale, you can add them up. Say you want one number for +how well rested a student is, from sleep and stress. More sleep means better +rested, but more stress means worse, so first **reverse** stress by multiplying +its z-score by โˆ’1. Then the average of the two z-scores is a simple **composite +index**. + +% + mutate( + z_sleep = as.numeric(scale(sleep_hours)), + z_stress_reversed = as.numeric(scale(stress)) * -1, + rested = (z_sleep + z_stress_reversed) / 2 + ) + +standardised %>% + select(sleep_hours, stress, z_sleep, z_stress_reversed, rested) %>% + head()`} /> + +A positive `rested` means better rested than the average student on the two +measures together. Without the reversal, a lot of stress would have cancelled +out a lot of sleep, and the index would mean nothing. + +## The empirical rule For a normal distribution, the proportions within one, two and three SDs of the -mean are fixed. They are worth memorising, because they turn a z-score into an -intuition immediately. +mean are fixed: about 68, 95 and 99.7 per cent. This is the **empirical rule**, +or the 68-95-99.7 rule. It is worth memorising, because it turns a z-score into +an intuition immediately. % )`} /> About 68 per cent within one SD, 95 per cent within two, 99.7 per cent within -three โ€” and the population agrees to within a percentage point or so. Hold on to -the middle one. In two modules' time, "95 per cent of sample means lie within +three, and the population agrees to within a percentage point or so. + +The rule also works in counts. Of 300 students on a roughly normal exam, about +68%, or 204, score within one SD of the mean. The other 32% split evenly over +the two tails, so about 16%, or 48 students, score more than one SD above it. +Hold on to the middle number, 95. In two modules' time, "95 per cent of sample means lie within about two standard errors of the population mean" becomes the confidence interval. diff --git a/src/content/lessons/09-2-tables-of-counts.mdx b/src/content/lessons/09-2-tables-of-counts.mdx index d297fa3..8b7d71e 100644 --- a/src/content/lessons/09-2-tables-of-counts.mdx +++ b/src/content/lessons/09-2-tables-of-counts.mdx @@ -4,19 +4,23 @@ takes two categorical variables at once, and the tool is a table of counts. Each cell counts the people with that combination: Engineering and stayed, Engineering and left, and so on. The first variable you give `table()` goes down the side, the second across the top. This is a **contingency table**, or a -cross-table. +crosstab. + +BMS courses lay a crosstab out one way: the **independent variable in the +columns** and the dependent variable in the rows. Department might influence +leaving, so department goes across the top and `left_company` down the side. ## Totals -The row totals are the department sizes and the column totals are how many +The column totals are the department sizes and the row totals are how many stayed and left overall. All the margins add up to 480. - - -The two tables answer different questions. Rows give each department's leaving -rate: Support loses about 31% of its people, Sales about 16%. Columns describe -the leavers: a third of everyone who left came from Support. Put the variable -that might do the influencing, department, in the rows and divide by rows, and -the table reads as a comparison of rates. + + +The two tables answer different questions. Columns give each department's +leaving rate: Support loses about 31% of its people, Sales about 16%. Rows +describe the leavers: a third of everyone who left came from Support. With the +independent variable in the columns, the rule is: **percentage within the +columns, compare along the rows**. The difference between two departments' +percentages, in percentage points, is the **percentage difference** (epsilon) +from the research methods lessons. @@ -60,23 +66,25 @@ library(janitor) # A frequency table: counts and proportions for one variable. d %>% tabyl(department) -# A crosstab with row percentages, a total row and the counts in brackets. +# A crosstab: the dependent variable first (rows), the independent second +# (columns), with column percentages, a total row and the counts in brackets. d %>% - tabyl(department, left_company) %>% + tabyl(left_company, department) %>% adorn_totals("row") %>% - adorn_percentages("row") %>% + adorn_percentages("col") %>% adorn_pct_formatting(digits = 1) %>% - adorn_ns()`} /> + adorn_ns() %>% + adorn_title("combined")`} /> -The crosstab shows the same leaving rates as `prop.table(counts, margin = 1)`, -with the counts next to them. `adorn_percentages("col")` divides by columns -instead. Keep the steps in this order: totals before percentages, so the total -row gets its own percentage, then the formatting, then `adorn_ns()` to put the -counts back next to the percentages. +The crosstab shows the same leaving rates as `prop.table(counts, margin = 2)`, +with the counts next to them. Keep the steps in this order: totals before +percentages, so the total row gets its own percentage, then the formatting, +then `adorn_ns()` to put the counts back next to the percentages. +`adorn_title("combined")` names both variables in the corner. ## A picture of it -A stacked bar where every bar reaches 100% shows the row proportions. Map the +A stacked bar where every bar reaches 100% shows the column proportions. Map the outcome to `fill` and ask `geom_bar()` for `position = "fill"`. diff --git a/src/content/lessons/rm-1-research-questions.mdx b/src/content/lessons/rm-1-research-questions.mdx new file mode 100644 index 0000000..dad197b --- /dev/null +++ b/src/content/lessons/rm-1-research-questions.mdx @@ -0,0 +1,82 @@ +Statistics answers questions, but only some kinds of question. Before any data +is collected, a study needs a question that data can actually answer. This +module covers the research methods that come before the analysis: what makes a +question empirical, who and what you measure, who ends up in the sample, when a +relationship can be read as cause and effect, and how a design protects that +reading. + +## Three kinds of question + +Not every question can be settled with data. + +- A **normative** question asks what *should* be: "Should the university ban + phones in lectures?" Data can inform it, but the answer also rests on values. +- A **conceptual** question asks what a word means: "What do we mean by + student wellbeing?" It is settled by definition and argument. +- An **empirical** question asks what *is*: "Do students who use their phone in + lectures score lower on the exam?" Observation can answer it. + +Research in the social sciences starts from empirical questions, though a +normative question is often the reason the study is done at all. + + + +## Describing or explaining + +Empirical questions come in two kinds. + +- A **descriptive** question asks how things are: "What share of BMS students + sleeps less than seven hours?" It involves one variable, or several described + side by side. +- An **explanatory** question asks why: "Does sleep explain differences in exam + scores?" It links an independent variable (the possible cause) to a dependent + variable (the outcome). + +A good empirical research question names three things: the **units** (who or +what is studied), the **variables** (what is measured about them) and the +**setting** (where and when). "Do BMS students at the University of Twente who +sleep more score higher on their first statistics exam, in the 2025-2026 +academic year?" names all three. "Does sleep matter?" names none of them. + +## The wheel of science + +Theory and observation feed each other in a cycle, sometimes drawn as the +**wheel of science**. Going from theory to a hypothesis to observation is +**deduction**: you derive what you expect to see and check it. Going from +observations to a general pattern to theory is **induction**. A hypothesis is a +testable statement about the expected relation, such as "students who sleep +more score higher on the exam", and it comes with a direction you can check. + +Data can contradict a hypothesis but never prove a theory once and for all. +People tend to notice and remember the evidence that fits what they already +believe. That is **confirmation bias**, and it is the reason a study states its +hypothesis and its test before the data come in. + +## Research for decisions + +Organisations commission research to make better decisions. A policy decision +runs through phases, and research can support each one: describing the problem, +choosing between options, and evaluating the choice. An evaluation before a +policy starts, judging which option is likely to work, is an **ex ante** +evaluation. One while it runs, checking whether it is carried out as planned, +is a **process** evaluation. One afterwards, judging whether it worked, is an +**ex post** evaluation. + + diff --git a/src/content/lessons/rm-2-units-and-variables.mdx b/src/content/lessons/rm-2-units-and-variables.mdx new file mode 100644 index 0000000..4cf430b --- /dev/null +++ b/src/content/lessons/rm-2-units-and-variables.mdx @@ -0,0 +1,97 @@ +A research question names units and variables. Data puts them in a grid: the +**data matrix**, with one row per unit and one column per variable. Every +dataset in this course is one, and so is every spreadsheet you will get from a +survey tool. + +## Units of analysis and of observation + +The **unit of analysis** is what your conclusions are about: students, +employees, schools, countries. The **unit of observation** is what you actually +measure. They often coincide, but not always. You can survey individual +employees (observation) to compare departments (analysis) by averaging within +each department. + +Moving between levels has a trap. If countries with more internet users also +have higher average incomes, that does not show that the individuals who use +the internet are the richer ones. Concluding something about individuals from +data about groups is the **ecological fallacy**. + +## A data matrix in R + +The dplyr package ships a small data matrix about Star Wars characters. Each +row is a character, each column something recorded about them. + + + +`glimpse()` lists every variable with its type and its first values, and +`colnames()` gives just the names. In RStudio, `View(starwars)` opens the same +matrix as a spreadsheet you can scroll through. + +## Levels of measurement + +How you may summarise a variable depends on its **level of measurement**. The +course uses the mnemonic **D-NOIR**: + +- **Dichotomous**: two categories, such as passed or failed. +- **Nominal**: categories without an order, such as eye colour or programme. +- **Ordinal**: ordered categories without equal steps, such as "never, + sometimes, often" or a ranking. +- **Interval**: equal steps but no true zero, such as temperature in degrees + Celsius or a score on a 1 to 7 scale. +- **Ratio**: equal steps and a true zero, such as height, income or the number + of exams taken. + +Categories also have to be **exhaustive**, so every unit fits in one, and +**mutually exclusive**, so no unit fits in two. Age groups "18 to 25, 25 to 35" +fail the second test: a 25-year-old fits both. + + + +R stores the level in the column's type. Numbers are `numeric` or `integer`, +categories are `character` or `factor`. R does not know whether your numbers +are ordinal or ratio, so the decision what to compute stays yours. + + + +A count per category, as `table()` gives, works at every level. A mean only +makes sense from interval level upwards. + + + +## From concept to number + +A research question uses concepts, such as "stress" or "social support". To +measure one, you first make it precise (**conceptualisation**: what exactly do +we mean, and which dimensions does it have?) and then decide how to observe it +(**operationalisation**: which questions, scale or observation, with which +answer categories?). The result of measuring is a score per unit, which goes in +the data matrix. + +The usual **data collection methods** are questionnaires, interviews, +observation, experiments and existing documents or records. Data you collect +yourself for your question is **primary data**. Data someone else collected, +such as a national statistics office or an earlier study, is **secondary +data**: cheaper and often larger, but measured for someone else's purpose. + + diff --git a/src/content/lessons/rm-3-sampling.mdx b/src/content/lessons/rm-3-sampling.mdx new file mode 100644 index 0000000..c7ddda3 --- /dev/null +++ b/src/content/lessons/rm-3-sampling.mdx @@ -0,0 +1,108 @@ +You almost never measure everyone you want to say something about. You measure +a sample and generalise. Whether that generalisation holds depends far more on +*how* the sample was drawn than on how large it is. + +## Population, frame and sample + +- The **population** is everyone your conclusion is about: all BMS bachelor + students this year, say. +- The **sampling frame** is the list you actually draw from: the student + administration's mailing list. People missing from the frame can never end up + in the sample. +- The **sample** is who you invite, and the **response rate** is the share of + them who actually take part. If 400 are invited and 120 respond, the response + rate is 30%. + +## Probability and non-probability samples + +In a **probability sample** every unit in the frame has a known chance, larger +than zero, of being selected. + +- A **simple random sample** gives every unit the same chance, like drawing + names from a hat. +- A **stratified sample** first splits the population into groups (strata), + such as programmes, and draws at random within each, so every group is + represented in a fixed number. +- A **cluster sample** draws whole groups at random, such as tutorial groups, + and measures everyone in the chosen groups. + +In a **non-probability sample** the chances are unknown. + +- A **convenience sample** takes whoever is easy to reach. +- A **purposive sample** picks units deliberately for a reason, such as + experts. +- A **snowball sample** asks participants to recruit others. +- A **quota sample** fills fixed numbers per group, like stratified sampling, + but without drawing at random within the groups. + +## Seeing the difference in R + +The course dataset `wellbeing-population.csv` holds all 5000 students of an +imaginary programme pair, so here you can compare samples with the truth. + + + +A simple random sample of 100 lands close to that mean. + +% slice_sample(n = 100) +mean(random_sample$stress)`} /> + +Now a convenience sample: you hand out questionnaires at an 8:45 lecture, and +only the students who slept at least seven hours made it there. + +% + filter(sleep_hours >= 7) %>% + slice_sample(n = 100) +mean(lecture_sample$stress)`} /> + + + +Repeating each method many times shows the pattern. + +% filter(sleep_hours >= 7) +lecture_means <- replicate(500, mean(slice_sample(rested, n = 100)$stress)) + +c(true = mean(population$stress), random = mean(random_means), lecture = mean(lecture_means))`} /> + +The random samples scatter around the true mean: that scatter is **sampling +error**, and it shrinks as samples get larger. The lecture samples scatter +around a value about two points too low: that is **bias**, a systematic error +that no sample size removes. Nonresponse works the same way when the people who +do not respond differ from those who do. + +A stratified sample draws a fixed number from each group. In dplyr, group first +and then draw. + +% + group_by(programme) %>% + slice_sample(n = 20) %>% + ungroup() +count(per_programme, programme)`} /> + + + + diff --git a/src/content/lessons/rm-4-causality-and-third-variables.mdx b/src/content/lessons/rm-4-causality-and-third-variables.mdx new file mode 100644 index 0000000..80c6743 --- /dev/null +++ b/src/content/lessons/rm-4-causality-and-third-variables.mdx @@ -0,0 +1,146 @@ +An explanatory hypothesis claims that X affects Y. Data can support that claim +only if three conditions hold: + +1. **Time order**: X comes before Y. This is mostly settled by theory and + design, not by the data. +2. **Association**: X and Y go together in the data. +3. **No third variable** explains the association away. A relationship that a + third variable creates is **spurious**, like the classic one between the + number of storks and the number of babies across regions, which both depend + on how rural a region is. + +This lesson tests a hypothesis with a crosstab and then checks it against a +third variable, the way the course reader by Van der Kolk does. + +## The data + +Do EU regions that receive a lot of money from the EU support the EU more? For +360 regions we know the money received (low or high), public support for the EU +(low or high) and economic development (low or high). The data come as a +**reduced data matrix**: one row per combination, with the number of regions +that have it. `uncount()` from tidyr turns that back into one row per region. + +% uncount(n) +nrow(regions)`} /> + +## The bivariate table + +The course convention for a crosstab: the **independent variable in the +columns**, the dependent variable in the rows, and percentages that add up to +100 within each column. Then you compare along the rows. `tabyl()` takes the +row variable first and the column variable second. + +% + tabyl(support, money) %>% + adorn_totals("row") %>% + adorn_percentages("col") %>% + adorn_pct_formatting(digits = 0) %>% + adorn_ns()`} /> + +Of the regions receiving little money, 10% show high support; of those +receiving a lot, 33%. The **percentage difference**, written E (epsilon), is +33 โˆ’ 10 = 23 percentage points, in the expected direction. + +Is 23 points a meaningful difference? The course uses a rule of thumb that +depends on the number of cases N: the difference should be at least + +- 5 points when N is 1100 or more, +- 10 points when N is between 200 and 1100, +- 15 points when N is between 100 and 200, +- 20 points when N is between 50 and 100. + +With N = 360 the bar is 10 points, so 23 points counts. Module 9 replaces this +rule of thumb with a proper test. + + + +## Adding a test variable + +Economic development could be related to both the money a region receives and +its support for the EU. To check, split the table by development: one +**partial table** per value of the test variable. A third argument to `tabyl()` +does exactly that and returns a list of tables, which the `adorn_` functions +handle one by one. + +% + tabyl(support, money, development) %>% + adorn_percentages("col") %>% + adorn_pct_formatting(digits = 0)`} /> + +Among low-development regions (N = 167) the difference is 14 โˆ’ 2 = 12 points. +Among high-development regions (N = 193) it is 43 โˆ’ 25 = 18 points. Both +partial tables have N between 100 and 200, so each needs 15 points. + +The relationship has weakened in both tables compared with the original 23 +points. Applied strictly, the rule of thumb says it disappears among +low-development regions and stays among high-development ones. Numbers this +close to the bar are fragile, which is one reason Module 9 brings in a test. + +The other expectations concern the test variable itself. Is development related +to the money a region receives, and to support? + +% + tabyl(money, development) %>% + adorn_percentages("col") %>% + adorn_pct_formatting(digits = 0)`} /> + +High-development regions receive a lot of money more often (73%) than +low-development ones (42%). Now the same for support, which is the exercise. + + + +## Elaboration models + +What a test variable does to the original relationship decides how you read it. +The course distinguishes these **elaboration models**: + +- **Replication**: the relationship stays the same in every partial table. +- **Addition**: the relationship stays the same, and the test variable also + affects Y on its own. +- **Full explanation**: the relationship disappears, and the test variable + comes *before* X and affects both. The original relationship was spurious. + The test variable is a **confounder**. +- **Partial explanation**: as full explanation, but the relationship only + weakens. +- **Full interpretation**: the relationship disappears, and the test variable + comes *between* X and Y: X affects it, and it affects Y. The test variable is + an intervening variable, or **mediator**. +- **Partial interpretation**: as full interpretation, but the relationship only + weakens. +- **Specification**: the relationship differs between the partial tables, for + example strong for one value of the test variable and absent for the other. + The test variable is a **moderator**, and this is an **interaction**. + +Explanation and interpretation give the *same* tables. Only the time order, +which comes from theory, tells them apart: does development come before EU +money, or does EU money raise development? Module 11 does the same with +regression ("holding constant"), Module 13 with interactions, and the Advanced +mediation lesson with mediators. + + diff --git a/src/content/lessons/rm-5-research-designs.mdx b/src/content/lessons/rm-5-research-designs.mdx new file mode 100644 index 0000000..d3bc427 --- /dev/null +++ b/src/content/lessons/rm-5-research-designs.mdx @@ -0,0 +1,107 @@ +The previous lesson checked for third variables after the data were in. A good +design rules many of them out before any data are collected. Designs are +written in a short notation: + +- **X** is the intervention, or treatment. +- **O** is an observation, a measurement. +- **R** means the groups were formed by random assignment; **N** means they + were not (a non-equivalent group, such as an existing class). + +Time runs from left to right, and each line is one group. + +## Designs from weak to strong + +A **one-shot case study**, `X O`, measures once after the intervention. With +nothing to compare against, it shows almost nothing. + +A **one-group pretest-posttest design**, `O X O`, measures before and after. +Any change could also come from something else that happened in between. + +An **interrupted time series**, `O O O X O O O`, measures several times before +and after. A trend that was already there before X is then visible, and a jump +right after X is more convincing. + +A **non-equivalent control group design** adds a comparison group that was not +randomised: + +``` +N O X O +N O O +``` + +A **pretest-posttest control group design** assigns people to the groups at +random. This is the classic **experiment**: + +``` +R O X O +R O O +``` + +Random assignment makes the groups equal *on average* in everything, measured +or not, so a difference afterwards can be put down to X or to chance, and +chance is what a statistical test measures. A **posttest-only control group +design**, `R X O` against `R O`, drops the pretest and avoids its side effects. + +## Random assignment in R + +`sample()` shuffles a vector. Shuffle a list of 240 "treatment" and 240 +"control" labels, and each of 480 employees gets one at random. + + + +The two groups differ a little in tenure, but only by chance. Compare that with +the `training` column in the same data: employees *chose* whether to follow the +training, so anything that drove that choice, such as motivation, travels along +with it. That is **self-selection**, and it is what random assignment removes. + + + +## Validity and its threats + +A study has **internal validity** when its conclusion about cause and effect +holds for the people studied. It has **external validity** when the conclusion +generalises to other people, settings and times. Random assignment protects +internal validity; random *sampling* (previous lesson) protects external +validity. They are different steps, and an experiment on first-year +psychology students can be internally valid and still say little about +employees. + +The usual **threats to internal validity**, each a rival explanation for a +change from O to O: + +- **History**: something else happened between the measurements, such as a + news event. +- **Maturation**: participants changed by themselves, by getting older, more + tired or more experienced. +- **Testing**: the first measurement affects the second, for example because + people remember the questions. +- **Instrumentation**: the measuring instrument or the observer changed. +- **Regression to the mean**: a group selected for extreme scores scores less + extreme the next time, even without any intervention. +- **Selection**: the groups differed before X, because they were not + randomised. +- **Attrition**: participants drop out, and those who drop out differ from + those who stay. +- **Social interaction**: groups learn from each other, compete, or the + control group feels disadvantaged. + +Experiments also guard against expectations. A **placebo** gives the control +group something that looks like the treatment but lacks its active part. In a +**double blind** study neither the participants nor the researchers who measure +them know who got what. + + diff --git a/src/content/manifest.ts b/src/content/manifest.ts index 86114be..9be3b18 100644 --- a/src/content/manifest.ts +++ b/src/content/manifest.ts @@ -18,6 +18,8 @@ export type LessonMeta = { export type ModuleMeta = { id: string; number: number; + /** Shown in place of "Module {number}" for a module outside the numbered run. */ + badge?: string; title: string; lessons: LessonMeta[]; }; @@ -26,11 +28,53 @@ export type ModuleMeta = { * The curriculum as planned: spec ยง7, Module 0 and the fourteen modules after * it, Module 9 on counts and proportions (inserted later, which moved the old * Modules 9 to 15 up one; see migrate() in src/state/progress.ts), then the - * advanced Modules 16 and 17. A module appears in MODULES below only once every one of its + * advanced Modules 16 and 17. The research methods module (badge RM) comes + * first, as in the BMS psychology course, without renumbering the others. + * A module appears in MODULES below only once every one of its * lesson files exists, so this list can be complete while the course is still * being written. */ export const PLANNED_MODULES: ModuleMeta[] = [ + { + id: 'module-rm', + // Internal only: the badge is what students see. + number: -1, + badge: 'RM', + title: 'Research methods', + lessons: [ + { + id: 'rm-1', + title: 'Empirical research and research questions', + file: 'rm-1-research-questions', + exercises: [], + }, + { + id: 'rm-2', + title: 'Units, variables and measurement', + file: 'rm-2-units-and-variables', + exercises: ['mrm-2-a'], + }, + { + id: 'rm-3', + title: 'Who ends up in the sample', + file: 'rm-3-sampling', + exercises: ['mrm-3-a'], + }, + { + id: 'rm-4', + title: 'Causality and third variables', + file: 'rm-4-causality-and-third-variables', + exercises: ['mrm-4-a'], + packages: ['janitor'], + }, + { + id: 'rm-5', + title: 'Research designs', + file: 'rm-5-research-designs', + exercises: ['mrm-5-a'], + }, + ], + }, { id: 'module-00', number: 0, @@ -132,6 +176,7 @@ export const PLANNED_MODULES: ModuleMeta[] = [ title: 'Summarising a column', file: '03-1-summaries', exercises: ['m3-1-a', 'm3-1-b'], + packages: ['janitor'], }, { id: '03-2', @@ -591,6 +636,16 @@ export const MODULES: ModuleMeta[] = PLANNED_MODULES.filter((module) => export const ALL_LESSONS: LessonMeta[] = MODULES.flatMap((module) => module.lessons); +/** "Module 3", or the badge of a module outside the numbered run. */ +export function moduleLabel(module: ModuleMeta): string { + return module.badge ?? `Module ${module.number}`; +} + +/** "Lesson 3-2" or "Lesson RM-1": the id, as the guide and review show it. */ +export function lessonLabel(lesson: LessonMeta): string { + return `Lesson ${lesson.id.replace(/^0/, '').toUpperCase()}`; +} + export function findLesson(id: string): LessonMeta | undefined { return ALL_LESSONS.find((lesson) => lesson.id === id); } diff --git a/src/content/parts.ts b/src/content/parts.ts index 1ea1163..a3ffae5 100644 --- a/src/content/parts.ts +++ b/src/content/parts.ts @@ -1,10 +1,12 @@ import { MODULES, type ModuleMeta } from './manifest'; /** - * Spec ยง7 groups the modules into three parts. Module 0 opens the first, and a - * fourth part holds the advanced modules that go beyond the core course. + * Spec ยง7 groups the modules into three parts, and Module 0 opens the first. + * Research methods comes before them, as in the BMS psychology course, and a + * last part holds the advanced modules that go beyond the core course. */ export const PARTS = [ + { title: 'Research methods', blurb: 'Questions, data, sampling, causality and designs', modules: [-1] }, { title: 'Foundations', blurb: 'Setting up, R, data, summaries and plots', modules: [0, 1, 2, 3, 4] }, { title: 'Inference', blurb: 'From one sample to the population', modules: [5, 6, 7, 8, 9] }, { title: 'The linear model', blurb: 'One model, many names', modules: [10, 11, 12, 13, 14, 15] }, diff --git a/src/content/reading.test.ts b/src/content/reading.test.ts index d515d04..708351b 100644 --- a/src/content/reading.test.ts +++ b/src/content/reading.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'vitest'; -import { ALL_LESSONS, MODULES } from './manifest'; +import { ALL_LESSONS } from './manifest'; +import { visibleModules } from './parts'; import { BOOK_CHAPTERS, READING } from './reading'; describe('read-along references', () => { @@ -17,7 +18,7 @@ describe('read-along references', () => { }); test('every lesson in the core course has something to read along', () => { - for (const module of MODULES.filter((m) => m.number <= 15)) { + for (const module of visibleModules(false)) { for (const lesson of module.lessons) expect(READING[lesson.id], lesson.id).toBeDefined(); } }); diff --git a/src/content/reading.ts b/src/content/reading.ts index 05ca189..767c6be 100644 --- a/src/content/reading.ts +++ b/src/content/reading.ts @@ -14,15 +14,28 @@ export function bookChapterHref(chapter: number): string { return `${BOOK_BASE}${BOOK_CHAPTERS[chapter - 1]}.html`; } -export type Reading = { book?: number[]; appendix?: (keyof typeof BOOK_APPENDICES)[]; guide?: string }; +export type Reading = { + book?: number[]; + appendix?: (keyof typeof BOOK_APPENDICES)[]; + /** Chapters of the BMS guide "Intro R for BMS Bachelor programs". */ + guide?: string; + /** Sections of van der Kolk's short guide to research questions, hypotheses and crosstabs. */ + shortGuide?: string; +}; /** * Where each lesson's material sits in the course's sources: chapters of the - * online book, and of the BMS guide "Intro R for BMS Bachelor programs". + * online book, of the BMS guide "Intro R for BMS Bachelor programs", and + * sections of the research methods course's short guide. * Shown at the top of the lesson so a student can read along. A lesson the * sources do not cover (the advanced modules, mostly) has no entry. */ export const READING: Record = { + 'rm-1': { shortGuide: '1 and 2' }, + 'rm-2': { book: [1], shortGuide: '3 and 4' }, + 'rm-3': { book: [2] }, + 'rm-4': { book: [4], shortGuide: '3 and 5' }, + 'rm-5': { shortGuide: '1' }, '00-1': { guide: '1' }, '00-2': { guide: '1' }, '00-3': { guide: '1' }, diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 6034259..d7cd582 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -92,7 +92,7 @@ export default function Home() { return (
    - {done === total ? 'โœ“' : module.number} + {done === total ? 'โœ“' : module.badge ?? module.number}

    {module.title}

    diff --git a/src/pages/Lesson.tsx b/src/pages/Lesson.tsx index 19c7ded..8e61a55 100644 --- a/src/pages/Lesson.tsx +++ b/src/pages/Lesson.tsx @@ -10,7 +10,7 @@ import { exercisesPassed, lessonStatus, moduleOf } from '../state/stats'; import { visibleModules } from '../content/parts'; import { useAdvanced } from '../state/settings'; import { APPENDIX_NAMES, bookAppendixHref, bookChapterHref, READING, type Reading } from '../content/reading'; -import { R_MATERIALS, REFERENCE_BOOK } from '../components/Credits'; +import { R_MATERIALS, REFERENCE_BOOK, RM_MATERIALS } from '../components/Credits'; const lessonModules = import.meta.glob<{ default: ComponentType<{ components?: unknown }> }>( '../content/lessons/*.mdx', @@ -80,7 +80,7 @@ export default function Lesson() {
    {module && (

    - Module {module.number} ยท {module.title} + {module.badge ? module.title : `Module ${module.number} ยท ${module.title}`} Lesson {position} of {module.lessons.length}

    )} @@ -136,6 +136,12 @@ function ReadAlong({ reading }: { reading: Reading }) { {R_MATERIALS.guide.title}, chapter{reading.guide.length > 2 ? 's' : ''} {reading.guide} )} + {(links.length > 0 || reading.guide) && reading.shortGuide && '; '} + {reading.shortGuide && ( + <> + {RM_MATERIALS.shortGuide.author}, {RM_MATERIALS.shortGuide.title}, section{reading.shortGuide.length > 2 ? 's' : ''} {reading.shortGuide} + + )}

    ); } diff --git a/src/pages/Review.tsx b/src/pages/Review.tsx index f9acefd..1a45df6 100644 --- a/src/pages/Review.tsx +++ b/src/pages/Review.tsx @@ -9,7 +9,7 @@ import { getProgress } from '../state/progress'; import { firstUnfinished } from '../state/stats'; import './ModelChooser.css'; -const label = (lessonId: string) => `Lesson ${lessonId.replace(/^0/, '')}`; +const label = (lessonId: string) => `Lesson ${lessonId.replace(/^0/, '').toUpperCase()}`; export default function Review() { const [round, setRound] = useState(null); From 7109975683f3b80040d7a7dc7fd383b206084883 Mon Sep 17 00:00:00 2001 From: Peter Slijkhuis Date: Mon, 19 Oct 2026 11:12:50 +0000 Subject: [PATCH 8/8] Fix option buttons failing axe 4.14's label-in-name check The model chooser and sample size planner named each option button after its title only, while the button also shows an example and a note. axe 4.14 reports that as label-content-name-mismatch (WCAG 2.5.3), which failed the weekly update. The button now takes its name from its own text, with the extra lines hidden from the name and kept as its description. The accessibility check now also covers the research methods lessons. --- e2e/accessibility.spec.ts | 2 +- src/pages/ModelChooser.tsx | 4 ++-- src/pages/SampleSize.tsx | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/e2e/accessibility.spec.ts b/e2e/accessibility.spec.ts index e2157c9..c88b256 100644 --- a/e2e/accessibility.spec.ts +++ b/e2e/accessibility.spec.ts @@ -8,7 +8,7 @@ import { expect, test, type Page } from '@playwright/test'; // Scroll-in animations fade text from transparent, which axe would flag mid-fade. test.use({ reducedMotion: 'reduce' }); -const LESSONS = [...readFileSync('src/content/manifest.ts', 'utf8').matchAll(/id: '(\d\d-\d)'/g)].map((m) => m[1]); +const LESSONS = [...readFileSync('src/content/manifest.ts', 'utf8').matchAll(/id: '((?:\d\d|rm)-\d)'/g)].map((m) => m[1]); const PAGES = ['./', './workspace', './which-model', './sample-size', './review', './avatar', ...LESSONS.map((id) => `./lesson/${id}`)]; async function violations(page: Page) { diff --git a/src/pages/ModelChooser.tsx b/src/pages/ModelChooser.tsx index f239d73..4daaee0 100644 --- a/src/pages/ModelChooser.tsx +++ b/src/pages/ModelChooser.tsx @@ -493,11 +493,11 @@ export default function ModelChooser() { const flagged = option.label === ahead; return (
  • -