Skip to the content
On this page
  1. Two versions of every experiment
  2. The experiments
  3. Experiment 1 — Mean, median, mode, variance, SD
  4. Experiment 2 — Binomial, normal, Poisson
  5. Experiment 3 — t-test and chi-square
  6. Experiment 4 — Correlation and regression
  7. Experiment 5 — EDA on a real dataset
  8. Experiment 6 — Feature engineering
  9. Experiment 7 — Variables, control structures, functions
  10. Experiment 8 — CSV, Excel, JSON, XML
  11. Experiment 9 — dplyr and tidyr
  12. Experiment 10 — Missing data and outliers
  13. Experiment 11 — Dates and times
  14. Experiment 12 — ggplot2
  15. Experiment 13 — K-Means clustering
  16. Experiment 14 — Confusion matrix, accuracy, ROC
  17. Experiment 15 — Text mining and word cloud
  18. Experiment 16 — ARIMA forecasting
  19. Experiment 17 — Interactive plots with plotly
  20. Experiment 18 — Shiny app with CSV upload
  21. Lab exam tips
  22. Written-out instructions
  23. One topic, one language, one page

18 practicals, each set out as 1. Question, 2. Aim, 3. Steps, 4. Programme, 5. Execution and Results.

Two versions of every experiment

Location Status
R — as the exam tests it labs/course-6-r/ ✅ executed, with R 4.3.3
Python equivalents labs/course-6-r/python/ ✅ executed, with assertions

Every R script is run, and under 5. Execution and Results is what it printed, with the plots it drew. Two cannot simply be run in a terminal: 17_plotly.R builds charts meant for RStudio's Viewer, and 18_shiny_app.R is a web app. Their drivers, _drive_17_plotly.py and _drive_18_shiny_app.py, open them in Chromium, use them as you would, and take the screenshots.

Until October 2026 R could not be installed where this material is verified, and these scripts were desk-checked only, with the numbers in their comments taken from the Python equivalents. Running them found four comments whose numbers R does not give, in experiments 4, 10, 13 and 16; each is corrected, with a note in the file and on this page.

What the Python side buys you: the same calculation, done a second way. When 04_regression.R says the slope is 4.3030, R prints it, and 04_regression.py computes it again from the sums, asserts it, and cross-checks it against Statistical Foundations for Data Science Unit 4, where the same data was worked by hand.

python3 tools/data-science/run_r_equivalents.py

That runs all 14 Python equivalents and 17 of the R scripts (the Shiny app is a server, which capture_lab_outputs.py runs instead), and checks all 18 for balanced delimiters.


The experiments

# Experiment R file Python Unit
1 Mean, median, mode, variance, SD 01_descriptive.R ✅ 1
2 Binomial, normal, Poisson 02_distributions.R ✅ 1
3 t-test and chi-square 03_hypothesis_tests.R ✅ 1
4 Correlation and regression 04_regression.R ✅ 4
5 EDA on a real dataset 05_eda.R ✅ 1
6 Feature engineering 06_feature_engineering.R ✅ 1
7 Variables, control structures, functions 07_r_basics.R — 2
8 CSV, Excel, JSON, XML 08_file_io.R ✅ 2
9 dplyr and tidyr 09_wrangling.R ✅ 3
10 Missing data and outliers 10_missing_outliers.R ✅ 3
11 Dates and times 11_dates.R ✅ 3
12 ggplot2 12_ggplot.R — 3
13 K-Means clustering 13_kmeans.R ✅ 4
14 Confusion matrix, accuracy, ROC 14_evaluation.R ✅ 4
15 Text mining and word cloud 15_text_mining.R ✅ 4
16 ARIMA forecasting 16_arima.R ✅ 5
17 Interactive plots with plotly 17_plotly.R — 5
18 Shiny app with CSV upload 18_shiny_app.R — 5

Experiments 7, 12, 17 and 18 have no Python equivalent: they demonstrate R syntax, ggplot2's grammar, plotly's R interface and the Shiny framework. A translation would teach nothing.


Experiment 1 — Mean, median, mode, variance, SD

1. Question

Find the mean, median, mode, variance and standard deviation of twenty students' marks.

2. Aim

Describe the centre and the spread of a set of marks in R, and know which variance R gives.

3. Steps

In R, 01_descriptive.R:

  1. Enter the marks.
  2. Find the centre: mean, median and mode.
  3. Measure the spread: variance, SD, range and quartiles.
  4. Convert to the population variance.
  5. Summarise everything at once.

In Python, python/01_descriptive.py:

  1. Compute the centre and the spread.
  2. Print them, against the R function for each.
  3. Check them against the statistics module.

SAMPLE OR POPULATION

R's var() and sd() divide by n − 1: they are the sample versions. For the population variance, multiply by (n − 1)/n, as the script does. R also has no function for the statistical mode — mode() reports how a value is stored — so the script writes one.

4. Programme

In R, 01_descriptive.R:

# =====================================================================
# Run with R 4.3.3 (Rscript --vanilla). What it prints, and the plots it
# draws, are on the lab page, and tools/data-science/run_r_equivalents.py
# runs it again. (Until October 2026 R could not be installed where these
# labs are checked, so this file was desk-checked only; every number in its
# comments has since been checked against R's own output.)
# =====================================================================
# Experiment 1: Mean, Median, Mode, Variance, Standard Deviation
# Python equivalent: python/01_descriptive.py

# Step 1: Enter the marks
marks <- c(45, 67, 78, 52, 89, 91, 73, 64, 58, 82,
           76, 69, 71, 85, 60, 55, 93, 48, 79, 66)

# --- Central tendency ---
# Step 2: Find the centre: mean, median and mode
mean(marks)      # 70.05
median(marks)    # 70.00

# R has NO built-in mode() for the statistical mode -- mode() reports the
# storage type. Define one, which the syllabus expects you to know:
statistical_mode <- function(v) {
  freq <- table(v)
  as.numeric(names(freq)[freq == max(freq)])
}
statistical_mode(marks)   # every value occurs once here, so all 20 are returned

# --- Dispersion ---
# Step 3: Measure the spread: variance, SD, range and quartiles
var(marks)       # 202.8921   <- R's var() divides by n-1 (SAMPLE)
sd(marks)        #  14.2440   <- likewise
range(marks)     # 45 93
diff(range(marks))  # 48
IQR(marks)
quantile(marks)

# Step 4: Convert to the population variance
# Population variance, if you need it, must be computed explicitly:
n <- length(marks)
var(marks) * (n - 1) / n     # 192.7475
sqrt(var(marks) * (n - 1) / n)   # 13.8834

# --- Everything at once ---
# Step 5: Summarise everything at once
summary(marks)

# NOTE FOR THE EXAM: R's var() and sd() are the n-1 versions. If a question
# asks for the population variance you must convert, as above. This is the
# same n vs n-1 distinction as Course 4 Unit 1.

In Python, python/01_descriptive.py:

"""Experiment 1 (Python equivalent) -- mean, median, mode, variance, SD.

R version: ../01_descriptive.R
The R script quotes these numbers; this file is what verifies them.
"""

import statistics
from collections import Counter

from _shared import MARKS


# Step 1: Compute the centre and the spread
def describe(values):
    n = len(values)
    mean = sum(values) / n
    ordered = sorted(values)
    median = (statistics.median(values))
    counts = Counter(values)
    top = max(counts.values())
    modes = sorted(v for v, c in counts.items() if c == top)

    # R's var() and sd() are the SAMPLE versions -- divide by n-1.
    sample_var = sum((x - mean) ** 2 for x in values) / (n - 1)
    pop_var = sum((x - mean) ** 2 for x in values) / n

    return {
        "n": n, "mean": mean, "median": median,
        "mode": modes if top > 1 else None,
        "sample_var": sample_var, "sample_sd": sample_var ** 0.5,
        "pop_var": pop_var, "pop_sd": pop_var ** 0.5,
        "range": max(values) - min(values),
    }


if __name__ == "__main__":
    # Step 2: Print them, against the R function for each
    r = describe(MARKS)
    print("EXPERIMENT 1 -- Descriptive statistics")
    print(f"  n            = {r['n']}")
    print(f"  mean         = {r['mean']:.4f}")
    print(f"  median       = {r['median']:.4f}")
    print(f"  mode         = {r['mode'] if r['mode'] else 'none (all values unique)'}")
    print(f"  range        = {r['range']}")
    print(f"  var  (n-1)   = {r['sample_var']:.4f}   <- R's var()")
    print(f"  sd   (n-1)   = {r['sample_sd']:.4f}   <- R's sd()")
    print(f"  var  (n)     = {r['pop_var']:.4f}   <- population")
    print(f"  sd   (n)     = {r['pop_sd']:.4f}")

    # Step 3: Check them against the statistics module
    assert abs(r["mean"] - statistics.mean(MARKS)) < 1e-9
    assert abs(r["sample_var"] - statistics.variance(MARKS)) < 1e-9
    assert abs(r["pop_var"] - statistics.pvariance(MARKS)) < 1e-9
    print("\n  cross-checked against the statistics module ✓")
    print("\n  NOTE: R's var() and sd() use n-1. R has no built-in mode();")
    print("        the R script defines one, as the syllabus expects.")

5. Execution and Results

In R, 01_descriptive.R:

OUTPUT

[1] 70.05
[1] 70
 [1] 45 48 52 55 58 60 64 66 67 69 71 73 76 78 79 82 85 89 91 93
[1] 202.8921
[1] 14.24402
[1] 45 93
[1] 48
[1] 20.25
   0%   25%   50%   75%  100%
45.00 59.50 70.00 79.75 93.00
[1] 192.7475
[1] 13.88335
   Min. 1st Qu.  Median    Mean 3rd Qu.    Max.
  45.00   59.50   70.00   70.05   79.75   93.00

In Python, python/01_descriptive.py:

OUTPUT

EXPERIMENT 1 -- Descriptive statistics
  n            = 20
  mean         = 70.0500
  median       = 70.0000
  mode         = none (all values unique)
  range        = 48
  var  (n-1)   = 202.8921   <- R's var()
  sd   (n-1)   = 14.2440   <- R's sd()
  var  (n)     = 192.7475   <- population
  sd   (n)     = 13.8834

  cross-checked against the statistics module ✓

  NOTE: R's var() and sd() use n-1. R has no built-in mode();
        the R script defines one, as the syllabus expects.

Every one of the twenty marks occurs once, so the mode function rightly returns all twenty. The Python equivalent gets the same mean, 70.05, and the same sample variance, 202.8921.

RESULT

The mean is 70.05 and the median 70; there is no single mode. The sample variance is 202.89 and the SD 14.24; the population variance is 192.75 and its SD 13.88.

Experiment 2 — Binomial, normal, Poisson

1. Question

Plot the Binomial(10, 0.3), Poisson(3) and Normal(100, 15) distributions, and find probabilities from each.

2. Aim

Draw three distributions in R, and find their probabilities with the d, p and q functions.

3. Steps

In R, 02_distributions.R:

  1. Plot Binomial(10, 0.3), and find P(X = 3) and P(X <= 3).
  2. Plot Poisson(3), and find P(X = 3) and P(X <= 3).
  3. Plot Normal(100, 15), and find the areas within 1, 2 and 3 SD.

In Python, python/02_distributions.py:

  1. Tabulate Binomial(10, 0.3).
  2. Tabulate Poisson(3).
  3. Find the normal's areas within 1, 2 and 3 SD.
  4. Check the figures the R comments quote.

THE D/P/Q/R NAMING CONVENTION

Worth memorising once, because it applies to every distribution in R:

Prefix Gives Example
d density / PMF dbinom(3, 10, 0.3) = 0.2668
p cumulative (CDF) pbinom(3, 10, 0.3) = 0.6496
q quantile (inverse) qnorm(0.95, 100, 15) = 124.67
r random generation rnorm(100, 100, 15)

4. Programme

In R, 02_distributions.R:

# =====================================================================
# Run with R 4.3.3 (Rscript --vanilla). What it prints, and the plots it
# draws, are on the lab page, and tools/data-science/run_r_equivalents.py
# runs it again. (Until October 2026 R could not be installed where these
# labs are checked, so this file was desk-checked only; every number in its
# comments has since been checked against R's own output.)
# =====================================================================
# Experiment 2: Visualise Binomial, Normal and Poisson distributions
# Python equivalent: python/02_distributions.py

# --- BINOMIAL(n = 10, p = 0.3) ---
# Step 1: Plot Binomial(10, 0.3), and find P(X = 3) and P(X <= 3)
k <- 0:10
pmf <- dbinom(k, size = 10, prob = 0.3)
barplot(pmf, names.arg = k, col = "#1e7fbf",
        main = "Binomial(10, 0.3)", xlab = "k", ylab = "P(X = k)")

dbinom(3, 10, 0.3)   # 0.266828  -- P(X = 3)
pbinom(3, 10, 0.3)   # 0.649611  -- P(X <= 3)
# mean = np = 3.0 ; variance = np(1-p) = 2.1

# --- POISSON(lambda = 3) ---
# Step 2: Plot Poisson(3), and find P(X = 3) and P(X <= 3)
k <- 0:10
barplot(dpois(k, lambda = 3), names.arg = k, col = "#059669",
        main = "Poisson(3)", xlab = "k", ylab = "P(X = k)")

dpois(3, 3)   # 0.224042
ppois(3, 3)   # 0.647232
# mean = variance = lambda = 3   <- the Poisson signature

# --- NORMAL(mu = 100, sigma = 15) ---
# Step 3: Plot Normal(100, 15), and find the areas within 1, 2 and 3 SD
x <- seq(50, 150, by = 0.5)
plot(x, dnorm(x, mean = 100, sd = 15), type = "l", lwd = 2, col = "#0f4c81",
     main = "Normal(100, 15)", ylab = "density")

pnorm(115, 100, 15)                          # 0.841345
pnorm(115, 100, 15) - pnorm(85, 100, 15)     # 0.682689  <- the 68% rule
pnorm(130, 100, 15) - pnorm(70, 100, 15)     # 0.954500  <- 95%
pnorm(145, 100, 15) - pnorm(55, 100, 15)     # 0.997300  <- 99.7%
qnorm(0.95, 100, 15)                         # 124.67 -- the 95th percentile

# NAMING CONVENTION -- worth memorising, it applies to every distribution:
#   d<name>  density / PMF        dbinom, dnorm, dpois
#   p<name>  cumulative (CDF)     pbinom, pnorm, ppois
#   q<name>  quantile (inverse)   qbinom, qnorm, qpois
#   r<name>  random generation    rbinom, rnorm, rpois

In Python, python/02_distributions.py:

"""Experiment 2 (Python equivalent) -- binomial, normal and Poisson.

R version: ../02_distributions.R
Uses the statlib module written for Course 4, so the two courses' distribution
numbers are guaranteed consistent.
"""

import pathlib
import sys

sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2]
                       / "course-4-stats" / "python"))
import statlib as S     # noqa: E402


def binomial_table(n, p):
    return [(k, S.binomial_pmf(k, n, p), S.binomial_cdf(k, n, p))
            for k in range(n + 1)]


def poisson_table(lam, upto):
    return [(k, S.poisson_pmf(k, lam), S.poisson_cdf(k, lam))
            for k in range(upto + 1)]


if __name__ == "__main__":
    # Step 1: Tabulate Binomial(10, 0.3)
    n, p = 10, 0.3
    print(f"BINOMIAL(n={n}, p={p})           R: dbinom(k, {n}, {p})")
    print(f"  {'k':<4}{'P(X=k)':<12}{'P(X<=k)':<12}")
    for k, pmf, cdf in binomial_table(n, p):
        print(f"  {k:<4}{pmf:<12.6f}{cdf:<12.6f}")
    print(f"  mean = np = {n * p:.2f}    variance = np(1-p) = {n * p * (1 - p):.2f}")

    # Step 2: Tabulate Poisson(3)
    lam = 3
    print(f"\nPOISSON(lambda={lam})            R: dpois(k, {lam})")
    print(f"  {'k':<4}{'P(X=k)':<12}{'P(X<=k)':<12}")
    for k, pmf, cdf in poisson_table(lam, 8):
        print(f"  {k:<4}{pmf:<12.6f}{cdf:<12.6f}")
    print(f"  mean = variance = lambda = {lam}")

    # Step 3: Find the normal's areas within 1, 2 and 3 SD
    mu, sigma = 100, 15
    print(f"\nNORMAL(mu={mu}, sigma={sigma})       R: pnorm(x, {mu}, {sigma})")
    for k in (1, 2, 3):
        lo, hi = mu - k * sigma, mu + k * sigma
        prob = S.normal_cdf(hi, mu, sigma) - S.normal_cdf(lo, mu, sigma)
        print(f"  within {k} sd [{lo:6.1f},{hi:6.1f}] = {prob * 100:6.3f}%")

    # Step 4: Check the figures the R comments quote
    # The empirical rule, to four decimals -- these are the numbers the R
    # script's comments quote.
    assert abs((S.normal_cdf(115, mu, sigma) - S.normal_cdf(85, mu, sigma))
               - 0.682689) < 1e-5
    assert abs(S.binomial_pmf(3, 10, 0.3) - 0.266828) < 1e-5
    assert abs(S.poisson_pmf(3, 3) - 0.224042) < 1e-5
    print("\n  all values cross-checked against Course 4's statlib ✓")

5. Execution and Results

In R, 02_distributions.R:

OUTPUT

[1] 0.2668279
[1] 0.6496107
[1] 0.2240418
[1] 0.6472319
[1] 0.8413447
[1] 0.6826895
[1] 0.9544997
[1] 0.9973002
[1] 124.6728

02_distributions.R: chart 1 of 3, drawn by the program

02_distributions.R: chart 2 of 3, drawn by the program

02_distributions.R: chart 3 of 3, drawn by the program

In Python, python/02_distributions.py:

OUTPUT

BINOMIAL(n=10, p=0.3)           R: dbinom(k, 10, 0.3)
  k   P(X=k)      P(X<=k)
  0   0.028248    0.028248
  1   0.121061    0.149308
  2   0.233474    0.382783
  3   0.266828    0.649611
  4   0.200121    0.849732
  5   0.102919    0.952651
  6   0.036757    0.989408
  7   0.009002    0.998410
  8   0.001447    0.999856
  9   0.000138    0.999994
  10  0.000006    1.000000
  mean = np = 3.00    variance = np(1-p) = 2.10

POISSON(lambda=3)            R: dpois(k, 3)
  k   P(X=k)      P(X<=k)
  0   0.049787    0.049787
  1   0.149361    0.199148
  2   0.224042    0.423190
  3   0.224042    0.647232
  4   0.168031    0.815263
  5   0.100819    0.916082
  6   0.050409    0.966491
  7   0.021604    0.988095
  8   0.008102    0.996197
  mean = variance = lambda = 3

NORMAL(mu=100, sigma=15)       R: pnorm(x, 100, 15)
  within 1 sd [  85.0, 115.0] = 68.269%
  within 2 sd [  70.0, 130.0] = 95.450%
  within 3 sd [  55.0, 145.0] = 99.730%

  all values cross-checked against Course 4's statlib ✓

The three plots are the binomial's and the Poisson's bar charts and the normal curve. The areas within 1, 2 and 3 SD are 0.6827, 0.9545 and 0.9973: the 68–95–99.7 rule.

RESULT

P(X = 3) is 0.2668 for the binomial and 0.2240 for the Poisson; P(X ≤ 3) is 0.6496 and 0.6472. For the normal, P(X ≤ 115) = 0.8413, and the 95th percentile is 124.67.

Experiment 3 — t-test and chi-square

1. Question

Test whether two groups' mean scores differ, by the t-test, and whether region and purchase type are associated, by the chi-square test.

2. Aim

Run the t-test in its variants and the chi-square test of independence in R, and read their output.

3. Steps

In R, 03_hypothesis_tests.R:

  1. Enter the two groups.
  2. Run the pooled two-sample t-test.
  3. Run Welch's test, and check the equal-variance assumption.
  4. Run the one-sample and paired t-tests.
  5. Test region against purchase type by chi-square.
  6. Check the expected counts and the residuals.

In Python, python/03_hypothesis_tests.py:

  1. Run the pooled two-sample t-test.
  2. Run the chi-square test.
  3. Check against Course 4 Unit 5.

VAR.EQUAL CHANGES THE TEST

t.test(a, b, var.equal = TRUE) is the pooled t-test from Statistical Foundations for Data Science Unit 5. Omit it and R runs Welch's test, which does not assume equal variances and reports fractional degrees of freedom. Both are defensible; know which you ran. Check the assumption first with var.test() — on the lab data it gives F = 1.8618, p = 0.3682, so equal variances are reasonable.

4. Programme

In R, 03_hypothesis_tests.R:

# =====================================================================
# Run with R 4.3.3 (Rscript --vanilla). What it prints, and the plots it
# draws, are on the lab page, and tools/data-science/run_r_equivalents.py
# runs it again. (Until October 2026 R could not be installed where these
# labs are checked, so this file was desk-checked only; every number in its
# comments has since been checked against R's own output.)
# =====================================================================
# Experiment 3: t-test and Chi-Square test
# Python equivalent: python/03_hypothesis_tests.py
# Same data as Course 4 Unit 5, so the numbers must agree with those notes.

# Step 1: Enter the two groups
group_a <- c(78, 82, 75, 88, 79, 84, 80, 86, 77, 83)
group_b <- c(72, 75, 70, 78, 74, 71, 76, 73, 69, 77)

# --- TWO-SAMPLE t-TEST ---
# Step 2: Run the pooled two-sample t-test
t.test(group_a, group_b, var.equal = TRUE)
#   t = 4.7541, df = 18, p-value = 0.000159
#   -> reject H0: the two group means differ significantly
#   mean of A = 81.20, mean of B = 73.50, pooled variance = 13.1167

# Step 3: Run Welch's test, and check the equal-variance assumption
# var.equal = TRUE gives the POOLED t-test (Course 4 Unit 5).
# Omit it and R runs Welch's t-test instead, which does not assume equal
# variances and reports fractional degrees of freedom. Both are defensible;
# know which one you asked for.
t.test(group_a, group_b)          # Welch -- note the different df

# Check the equal-variance assumption first:
var.test(group_a, group_b)        # F = 1.8618, p = 0.3682 -> variances OK

# Step 4: Run the one-sample and paired t-tests
# One-sample and paired variants:
t.test(group_a, mu = 80)
t.test(group_a, group_b, paired = TRUE)

# --- CHI-SQUARE TEST OF INDEPENDENCE ---
# Step 5: Test region against purchase type by chi-square
observed <- matrix(c(30, 70, 45, 55, 25, 75), nrow = 3, byrow = TRUE,
                   dimnames = list(c("North", "South", "East"),
                                   c("Premium", "Standard")))
observed
chisq.test(observed)
#   X-squared = 9.75, df = 2, p-value = 0.007635
#   -> reject H0: region and purchase type ARE associated

# Step 6: Check the expected counts and the residuals
result <- chisq.test(observed)
result$expected     # all 33.33 / 66.67 -- every one >= 5, assumption satisfied
result$residuals    # which cells contribute most to the statistic

# NOTE byrow = TRUE. R's matrix() fills COLUMN-wise by default, so omitting it
# silently transposes your table and changes the answer.

In Python, python/03_hypothesis_tests.py:

"""Experiment 3 (Python equivalent) -- t-test and chi-square test.

R version: ../03_hypothesis_tests.R  (t.test() and chisq.test())
"""
import pathlib, sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "course-4-stats" / "python"))
import statlib as S     # noqa: E402

GROUP_A = [78, 82, 75, 88, 79, 84, 80, 86, 77, 83]
GROUP_B = [72, 75, 70, 78, 74, 71, 76, 73, 69, 77]

OBSERVED = [[30, 70], [45, 55], [25, 75]]      # region x purchase type


def describe(v):
    m = sum(v) / len(v)
    return m, sum((x - m) ** 2 for x in v) / (len(v) - 1)


def two_sample_t(a, b):
    ma, va = describe(a)
    mb, vb = describe(b)
    na, nb = len(a), len(b)
    pooled = ((na - 1) * va + (nb - 1) * vb) / (na + nb - 2)
    se = (pooled * (1 / na + 1 / nb)) ** 0.5
    t = (ma - mb) / se
    df = na + nb - 2
    return t, df, S.t_sf_two_tailed(t, df), pooled, se


def chi_square(observed):
    rows = [sum(r) for r in observed]
    cols = [sum(observed[i][j] for i in range(len(observed)))
            for j in range(len(observed[0]))]
    total = sum(rows)
    chi2 = 0.0
    expected = []
    for i, row in enumerate(observed):
        exp_row = []
        for j, o in enumerate(row):
            e = rows[i] * cols[j] / total
            exp_row.append(e)
            chi2 += (o - e) ** 2 / e
        expected.append(exp_row)
    df = (len(observed) - 1) * (len(observed[0]) - 1)
    return chi2, df, S.chi2_sf(chi2, df), expected


if __name__ == "__main__":
    # Step 1: Run the pooled two-sample t-test
    t, df, p, pooled, se = two_sample_t(GROUP_A, GROUP_B)
    print("TWO-SAMPLE t-TEST        R: t.test(a, b, var.equal = TRUE)")
    print(f"  mean A   = {sum(GROUP_A)/len(GROUP_A):.4f}")
    print(f"  mean B   = {sum(GROUP_B)/len(GROUP_B):.4f}")
    print(f"  pooled var = {pooled:.4f}    se = {se:.4f}")
    print(f"  t = {t:.4f}   df = {df}   p = {p:.6f}")
    print(f"  -> {'reject' if p < 0.05 else 'fail to reject'} H0 at alpha = 0.05")

    # Step 2: Run the chi-square test
    chi2, cdf, cp, expected = chi_square(OBSERVED)
    print("\nCHI-SQUARE TEST          R: chisq.test(matrix)")
    print("  expected frequencies:")
    for row in expected:
        print("   ", "  ".join(f"{e:7.3f}" for e in row))
    print(f"  chi-square = {chi2:.4f}   df = {cdf}   p = {cp:.6f}")
    print(f"  -> {'reject' if cp < 0.05 else 'fail to reject'} H0")
    print(f"  smallest expected = {min(min(r) for r in expected):.2f} (must be >= 5)")

    # Step 3: Check against Course 4 Unit 5
    # These must agree with Course 4 Unit 5, which uses the same data.
    assert abs(t - 4.754053) < 1e-5, t
    assert abs(chi2 - 9.75) < 1e-9
    print("\n  matches Course 4 Unit 5's worked examples ✓")

5. Execution and Results

In R, 03_hypothesis_tests.R:

OUTPUT


    Two Sample t-test

data:  group_a and group_b
t = 4.7541, df = 18, p-value = 0.0001585
alternative hypothesis: true difference in means is not equal to 0
95 percent confidence interval:
  4.297198 11.102802
sample estimates:
mean of x mean of y
     81.2      73.5


    Welch Two Sample t-test

data:  group_a and group_b
t = 4.7541, df = 16.503, p-value = 0.0001987
alternative hypothesis: true difference in means is not equal to 0
95 percent confidence interval:
  4.274943 11.125057
sample estimates:
mean of x mean of y
     81.2      73.5


    F test to compare two variances

data:  group_a and group_b
F = 1.8618, num df = 9, denom df = 9, p-value = 0.3682
alternative hypothesis: true ratio of variances is not equal to 1
95 percent confidence interval:
 0.4624493 7.4956691
sample estimates:
ratio of variances
          1.861818


    One Sample t-test

data:  group_a
t = 0.91856, df = 9, p-value = 0.3823
alternative hypothesis: true mean is not equal to 80
95 percent confidence interval:
 78.24473 84.15527
sample estimates:
mean of x
     81.2


    Paired t-test

data:  group_a and group_b
t = 7.4516, df = 9, p-value = 3.886e-05
alternative hypothesis: true mean difference is not equal to 0
95 percent confidence interval:
  5.362438 10.037562
sample estimates:
mean difference
            7.7

      Premium Standard
North      30       70
South      45       55
East       25       75

    Pearson's Chi-squared test

data:  observed
X-squared = 9.75, df = 2, p-value = 0.007635

       Premium Standard
North 33.33333 66.66667
South 33.33333 66.66667
East  33.33333 66.66667
         Premium   Standard
North -0.5773503  0.4082483
South  2.0207259 -1.4288690
East  -1.4433757  1.0206207

In Python, python/03_hypothesis_tests.py:

OUTPUT

TWO-SAMPLE t-TEST        R: t.test(a, b, var.equal = TRUE)
  mean A   = 81.2000
  mean B   = 73.5000
  pooled var = 13.1167    se = 1.6197
  t = 4.7541   df = 18   p = 0.000159
  -> reject H0 at alpha = 0.05

CHI-SQUARE TEST          R: chisq.test(matrix)
  expected frequencies:
     33.333   66.667
     33.333   66.667
     33.333   66.667
  chi-square = 9.7500   df = 2   p = 0.007635
  -> reject H0
  smallest expected = 33.33 (must be >= 5)

  matches Course 4 Unit 5's worked examples ✓

The pooled test has 18 degrees of freedom and Welch's 16.503: the same t, 4.7541, read against a slightly different distribution. The chi-square test's expected counts are all 33.33 or 66.67, so none is below 5.

RESULT

The two means, 81.2 and 73.5, differ significantly (t = 4.7541, df = 18, p = 0.00016). Region and purchase type are associated (χ² = 9.75, df = 2, p = 0.0076).

Experiment 4 — Correlation and regression

1. Question

Find the correlation between study hours and exam scores, fit a regression line, and predict the score for 7.5 hours.

2. Aim

Fit and read a simple linear regression in R with lm().

3. Steps

In R, 04_regression.R:

  1. Enter the hours and scores.
  2. Measure the correlation, and plot the points.
  3. Fit the regression line.
  4. Use the model: coefficients, intervals, a prediction, residuals.
  5. Read the ANOVA table.

In Python, python/04_regression.py:

  1. Compute r, the line and the ANOVA from the sums.
  2. Print the results.
  3. Check that R-squared = r^2 and F = t^2.

TWO FREE CHECKS

For a simple regression, R² is the square of r, and the F statistic is the square of the slope's t. Both hold here: 0.997904² = 0.995812, and 43.615² = 1902.26.

4. Programme

In R, 04_regression.R:

# =====================================================================
# Run with R 4.3.3 (Rscript --vanilla). What it prints, and the plots it
# draws, are on the lab page, and tools/data-science/run_r_equivalents.py
# runs it again. (Until October 2026 R could not be installed where these
# labs are checked, so this file was desk-checked only; every number in its
# comments has since been checked against R's own output.)
# =====================================================================
# Experiment 4: Correlation and simple linear regression
# Python equivalent: python/04_regression.py
# Same data as Course 4 Unit 4 -- coefficients must match those notes.

# Step 1: Enter the hours and scores
hours  <- c(2, 3, 4, 5, 6, 7, 8, 9, 10, 11)
scores <- c(52, 55, 61, 64, 70, 72, 78, 82, 85, 91)
df <- data.frame(hours, scores)

# Step 2: Measure the correlation, and plot the points
cor(hours, scores)                       # 0.997904 -- Pearson
cor(hours, scores, method = "spearman")  # rank correlation
cov(hours, scores)

plot(hours, scores, pch = 19, col = "#1e7fbf",
     main = "Exam score against study hours")

# Step 3: Fit the regression line
model <- lm(scores ~ hours, data = df)
summary(model)
#   (Intercept)  43.0303   Std.Error 0.7011   t 61.38
#   hours         4.3030   Std.Error 0.0987   t 43.615   p 8.43e-11
#   [Corrected: this gave the intercept's standard error as 1.0847 and its
#   t as 39.671. R prints 0.70111 and 61.38, and by hand
#   SE = 0.8961 x sqrt(1/10 + 6.5^2/82.5) = 0.7011.]
#   Multiple R-squared: 0.995812      F: 1902.26 on 1 and 8 DF
#
#   Fitted line:  scores = 43.0303 + 4.3030 * hours
#   Each extra hour of study is ASSOCIATED WITH about 4.3 more marks.

abline(model, col = "red", lwd = 2)

# Step 4: Use the model: coefficients, intervals, a prediction, residuals
coef(model)
confint(model)
predict(model, newdata = data.frame(hours = 7.5))    # 75.30
residuals(model)
par(mfrow = c(2, 2)); plot(model); par(mfrow = c(1, 1))   # diagnostics

# Step 5: Read the ANOVA table
anova(model)     # SS_reg = 1527.58, SS_res = 6.42, F = 1902.26

# TWO FREE ARITHMETIC CHECKS for simple regression (Course 4 Unit 4):
#   R-squared == r^2        0.997904^2 = 0.995812  ✓
#   F         == t^2        43.615^2   = 1902.26   ✓

In Python, python/04_regression.py:

"""Experiment 4 (Python equivalent) -- correlation and simple linear regression.

R version: ../04_regression.R  (cor() and lm())
Uses the same hours/scores pair as Course 4 Unit 4, so the coefficients here
must reproduce that unit's worked example exactly.
"""
import pathlib, sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "course-4-stats" / "python"))
import statlib as S     # noqa: E402
from _shared import HOURS, SCORES


# Step 1: Compute r, the line and the ANOVA from the sums
def regress(x, y):
    n = len(x)
    mx, my = sum(x) / n, sum(y) / n
    sxy = sum((a - mx) * (b - my) for a, b in zip(x, y))
    sxx = sum((a - mx) ** 2 for a in x)
    syy = sum((b - my) ** 2 for b in y)

    r = sxy / (sxx * syy) ** 0.5
    b1 = sxy / sxx
    b0 = my - b1 * mx

    ss_res = sum((b - (b0 + b1 * a)) ** 2 for a, b in zip(x, y))
    ss_reg = syy - ss_res
    r2 = ss_reg / syy
    ms_res = ss_res / (n - 2)
    se_b1 = (ms_res / sxx) ** 0.5
    t = b1 / se_b1
    f = ss_reg / ms_res
    return dict(n=n, r=r, b0=b0, b1=b1, r2=r2, ss_tot=syy, ss_res=ss_res,
                ss_reg=ss_reg, ms_res=ms_res, se_b1=se_b1, t=t, f=f,
                p=S.t_sf_two_tailed(t, n - 2))


if __name__ == "__main__":
    # Step 2: Print the results
    m = regress(HOURS, SCORES)
    print("CORRELATION AND REGRESSION      R: cor(x,y) ; lm(y ~ x)")
    print(f"  Pearson r      = {m['r']:.6f}")
    print(f"  intercept b0   = {m['b0']:.4f}")
    print(f"  slope     b1   = {m['b1']:.4f}")
    print(f"  fitted line: y = {m['b0']:.4f} + {m['b1']:.4f} x")
    print(f"\n  R-squared      = {m['r2']:.6f}   (= r^2 = {m['r']**2:.6f})")
    print(f"  SE(b1)         = {m['se_b1']:.4f}")
    print(f"  t              = {m['t']:.4f}  on {m['n']-2} df,  p = {m['p']:.3e}")
    print(f"  F              = {m['f']:.4f}   (= t^2 = {m['t']**2:.4f})")
    print("\n  ANOVA")
    print(f"    Regression  SS={m['ss_reg']:10.4f}  df=1")
    print(f"    Residual    SS={m['ss_res']:10.4f}  df={m['n']-2}  MS={m['ms_res']:.4f}")
    print(f"    Total       SS={m['ss_tot']:10.4f}  df={m['n']-1}")
    print(f"\n  predict at x=7.5 -> {m['b0'] + m['b1']*7.5:.2f}")

    # Step 3: Check that R-squared = r^2 and F = t^2
    assert abs(m["r2"] - m["r"] ** 2) < 1e-12,  "R2 must equal r squared"
    assert abs(m["f"] - m["t"] ** 2) < 1e-6,    "F must equal t squared"
    assert abs(m["b1"] - 4.3030) < 1e-3
    print("\n  R2 = r^2 and F = t^2 both hold; matches Course 4 Unit 4 ✓")

5. Execution and Results

In R, 04_regression.R:

OUTPUT

[1] 0.9979039
[1] 1
[1] 39.44444

Call:
lm(formula = scores ~ hours, data = df)

Residuals:
    Min      1Q  Median      3Q     Max
-1.1515 -0.8409  0.3030  0.6136  1.1515

Coefficients:
            Estimate Std. Error t value Pr(>|t|)
(Intercept) 43.03030    0.70111   61.38 5.52e-12 ***
hours        4.30303    0.09866   43.62 8.43e-11 ***
---
Signif. codes:  0 ‘***’ 0.001 ‘**’ 0.01 ‘*’ 0.05 ‘.’ 0.1 ‘ ’ 1

Residual standard error: 0.8961 on 8 degrees of freedom
Multiple R-squared:  0.9958,    Adjusted R-squared:  0.9953
F-statistic:  1902 on 1 and 8 DF,  p-value: 8.425e-11

(Intercept)       hours
   43.03030     4.30303
                2.5 %   97.5 %
(Intercept) 41.413546 44.64706
hours        4.075521  4.53054
       1
75.30303
         1          2          3          4          5          6          7
 0.3636364 -0.9393939  0.7575758 -0.5454545  1.1515152 -1.1515152  0.5454545
         8          9         10
 0.2424242 -1.0606061  0.6363636
Analysis of Variance Table

Response: scores
          Df  Sum Sq Mean Sq F value    Pr(>F)
hours      1 1527.58  1527.6  1902.3 8.425e-11 ***
Residuals  8    6.42     0.8
---
Signif. codes:  0 ‘***’ 0.001 ‘**’ 0.01 ‘*’ 0.05 ‘.’ 0.1 ‘ ’ 1

04_regression.R: chart 1 of 2, drawn by the program

04_regression.R: chart 2 of 2, drawn by the program

In Python, python/04_regression.py:

OUTPUT

CORRELATION AND REGRESSION      R: cor(x,y) ; lm(y ~ x)
  Pearson r      = 0.997904
  intercept b0   = 43.0303
  slope     b1   = 4.3030
  fitted line: y = 43.0303 + 4.3030 x

  R-squared      = 0.995812   (= r^2 = 0.995812)
  SE(b1)         = 0.0987
  t              = 43.6150  on 8 df,  p = 8.425e-11
  F              = 1902.2642   (= t^2 = 1902.2642)

  ANOVA
    Regression  SS= 1527.5758  df=1
    Residual    SS=    6.4242  df=8  MS=0.8030
    Total       SS= 1534.0000  df=9

  predict at x=7.5 -> 75.30

  R2 = r^2 and F = t^2 both hold; matches Course 4 Unit 4 ✓

Corrected: the comment under summary(model) gave the intercept's standard error as 1.0847, and its t as 39.671. R prints 0.70111 and 61.38, and by hand SE = 0.8961 × √(1/10 + 6.5²/82.5) = 0.7011. The slope's figures were right. The second plot is the four diagnostic plots, from plot(model).

RESULT

r = 0.9979, and the fitted line is score = 43.0303 + 4.3030 × hours, with R² = 0.9958. At 7.5 hours it predicts 75.30.

Experiment 5 — EDA on a real dataset

1. Question

Explore a dataset: its structure, summary statistics, missing values, categories, distributions, outliers and correlations.

2. Aim

Carry out the steps of exploratory data analysis in R, on the iris data.

3. Steps

In R, 05_eda.R:

  1. Load the data.
  2. Look at its structure and summary.
  3. Check for missing values.
  4. Count the categories.
  5. Plot the distributions, and find the outliers.
  6. Find the correlations.
  7. Judge the skew from the mean and median.

In Python, python/05_eda.py:

  1. Show the structure.
  2. Summarise the numeric columns.
  3. Count the missing values and the categories.
  4. Draw a text histogram, and find the outliers.
  5. Find the correlation.

4. Programme

In R, 05_eda.R:

# =====================================================================
# Run with R 4.3.3 (Rscript --vanilla). What it prints, and the plots it
# draws, are on the lab page, and tools/data-science/run_r_equivalents.py
# runs it again. (Until October 2026 R could not be installed where these
# labs are checked, so this file was desk-checked only; every number in its
# comments has since been checked against R's own output.)
# =====================================================================
# Experiment 5: Exploratory Data Analysis
# Python equivalent: python/05_eda.py
# Step 1: Load the data
data(iris)                       # or read.csv("yourfile.csv")

# Step 2: Look at its structure and summary
str(iris)                        # structure: 150 obs. of 5 variables
dim(iris); nrow(iris); ncol(iris)
head(iris); tail(iris)
summary(iris)                    # min, Q1, median, mean, Q3, max per column

# Step 3: Check for missing values
colSums(is.na(iris))             # missing values per column
sum(complete.cases(iris))        # rows with no NA at all

# Step 4: Count the categories
table(iris$Species)              # categorical counts
prop.table(table(iris$Species))  # as proportions

# Step 5: Plot the distributions, and find the outliers
hist(iris$Sepal.Length, breaks = 10, col = "#1e7fbf",
     main = "Distribution of sepal length")
boxplot(Sepal.Length ~ Species, data = iris, col = "#059669")
boxplot(iris$Sepal.Length)$out    # the outlier values themselves
pairs(iris[, 1:4], col = iris$Species)   # scatterplot matrix

# Step 6: Find the correlations
cor(iris[, 1:4])                 # correlation matrix -- numeric columns only

# Step 7: Judge the skew from the mean and median
# Skewness needs a package; the sign is what matters
# library(e1071); skewness(iris$Sepal.Length)
# mean > median  -> right-skewed ; mean < median -> left-skewed
mean(iris$Sepal.Length); median(iris$Sepal.Length)

In Python, python/05_eda.py:

"""Experiment 5 (Python equivalent) -- exploratory data analysis.

R version: ../05_eda.R  (str, summary, colSums(is.na()), hist, boxplot)
"""
from _shared import STUDENTS

COLS = ["name", "section", "gender", "hours", "marks", "attendance"]


def to_columns(rows):
    return {c: [r[i] for r in rows] for i, c in enumerate(COLS)}


def summarise_numeric(v):
    s = sorted(v)
    n = len(s)
    def q(p):
        pos = (n - 1) * p
        lo = int(pos)
        hi = min(lo + 1, n - 1)
        return s[lo] + (pos - lo) * (s[hi] - s[lo])
    return dict(min=s[0], q1=q(.25), median=q(.5), mean=sum(s)/n,
                q3=q(.75), max=s[-1])


if __name__ == "__main__":
    d = to_columns(STUDENTS)
    # Step 1: Show the structure
    print("STRUCTURE                      R: str(df)")
    print(f"  {len(STUDENTS)} observations of {len(COLS)} variables")
    for c in COLS:
        kind = "num" if isinstance(d[c][0], (int, float)) else "chr"
        print(f"    {c:<12} {kind}   e.g. {d[c][0]}")

    # Step 2: Summarise the numeric columns
    print("\nSUMMARY                        R: summary(df)")
    print(f"  {'':<12}{'Min':>8}{'1stQu':>9}{'Median':>9}{'Mean':>9}{'3rdQu':>9}{'Max':>8}")
    for c in ("hours", "marks", "attendance"):
        s = summarise_numeric(d[c])
        print(f"  {c:<12}{s['min']:>8.2f}{s['q1']:>9.2f}{s['median']:>9.2f}"
              f"{s['mean']:>9.2f}{s['q3']:>9.2f}{s['max']:>8.2f}")

    # Step 3: Count the missing values and the categories
    print("\nMISSING VALUES                 R: colSums(is.na(df))")
    for c in COLS:
        print(f"    {c:<12} {sum(1 for x in d[c] if x is None)}")

    print("\nCATEGORICAL COUNTS             R: table(df$section)")
    for c in ("section", "gender"):
        counts = {}
        for v in d[c]:
            counts[v] = counts.get(v, 0) + 1
        print(f"    {c}: " + ", ".join(f"{k}={v}" for k, v in sorted(counts.items())))

    # Step 4: Draw a text histogram, and find the outliers
    print("\nHISTOGRAM of marks             R: hist(df$marks)")
    for lo in range(40, 100, 10):
        n = sum(1 for m in d["marks"] if lo <= m < lo + 10)
        print(f"    {lo}-{lo+9}  {'#' * n * 3} {n}")

    print("\nOUTLIERS (1.5 x IQR)           R: boxplot(df$marks)$out")
    s = summarise_numeric(d["marks"])
    iqr = s["q3"] - s["q1"]
    lo, hi = s["q1"] - 1.5 * iqr, s["q3"] + 1.5 * iqr
    out = [m for m in d["marks"] if m < lo or m > hi]
    print(f"    IQR = {iqr:.2f}, fences = [{lo:.2f}, {hi:.2f}]")
    print(f"    outliers: {out if out else 'none'}")

    # Step 5: Find the correlation
    print("\nCORRELATION                    R: cor(df[, c('hours','marks')])")
    x, y = d["hours"], d["marks"]
    n = len(x); mx, my = sum(x)/n, sum(y)/n
    r = (sum((a-mx)*(b-my) for a, b in zip(x, y))
         / (sum((a-mx)**2 for a in x) * sum((b-my)**2 for b in y)) ** .5)
    print(f"    r(hours, marks) = {r:.4f}")

5. Execution and Results

In R, 05_eda.R:

OUTPUT

'data.frame':   150 obs. of  5 variables:
 $ Sepal.Length: num  5.1 4.9 4.7 4.6 5 5.4 4.6 5 4.4 4.9 ...
 $ Sepal.Width : num  3.5 3 3.2 3.1 3.6 3.9 3.4 3.4 2.9 3.1 ...
 $ Petal.Length: num  1.4 1.4 1.3 1.5 1.4 1.7 1.4 1.5 1.4 1.5 ...
 $ Petal.Width : num  0.2 0.2 0.2 0.2 0.2 0.4 0.3 0.2 0.2 0.1 ...
 $ Species     : Factor w/ 3 levels "setosa","versicolor",..: 1 1 1 1 1 1 1 1 1 1 ...
[1] 150   5
[1] 150
[1] 5
  Sepal.Length Sepal.Width Petal.Length Petal.Width Species
1          5.1         3.5          1.4         0.2  setosa
2          4.9         3.0          1.4         0.2  setosa
3          4.7         3.2          1.3         0.2  setosa
4          4.6         3.1          1.5         0.2  setosa
5          5.0         3.6          1.4         0.2  setosa
6          5.4         3.9          1.7         0.4  setosa
    Sepal.Length Sepal.Width Petal.Length Petal.Width   Species
145          6.7         3.3          5.7         2.5 virginica
146          6.7         3.0          5.2         2.3 virginica
147          6.3         2.5          5.0         1.9 virginica
148          6.5         3.0          5.2         2.0 virginica
149          6.2         3.4          5.4         2.3 virginica
150          5.9         3.0          5.1         1.8 virginica
  Sepal.Length    Sepal.Width     Petal.Length    Petal.Width
 Min.   :4.300   Min.   :2.000   Min.   :1.000   Min.   :0.100
 1st Qu.:5.100   1st Qu.:2.800   1st Qu.:1.600   1st Qu.:0.300
 Median :5.800   Median :3.000   Median :4.350   Median :1.300
 Mean   :5.843   Mean   :3.057   Mean   :3.758   Mean   :1.199
 3rd Qu.:6.400   3rd Qu.:3.300   3rd Qu.:5.100   3rd Qu.:1.800
 Max.   :7.900   Max.   :4.400   Max.   :6.900   Max.   :2.500
       Species
 setosa    :50
 versicolor:50
 virginica :50



Sepal.Length  Sepal.Width Petal.Length  Petal.Width      Species
           0            0            0            0            0
[1] 150

    setosa versicolor  virginica
        50         50         50

    setosa versicolor  virginica
 0.3333333  0.3333333  0.3333333
numeric(0)
             Sepal.Length Sepal.Width Petal.Length Petal.Width
Sepal.Length    1.0000000  -0.1175698    0.8717538   0.8179411
Sepal.Width    -0.1175698   1.0000000   -0.4284401  -0.3661259
Petal.Length    0.8717538  -0.4284401    1.0000000   0.9628654
Petal.Width     0.8179411  -0.3661259    0.9628654   1.0000000
[1] 5.843333
[1] 5.8

05_eda.R: chart 1 of 4, drawn by the program

05_eda.R: chart 2 of 4, drawn by the program

05_eda.R: chart 3 of 4, drawn by the program

05_eda.R: chart 4 of 4, drawn by the program

In Python, python/05_eda.py:

OUTPUT

STRUCTURE                      R: str(df)
  10 observations of 6 variables
    name         chr   e.g. Ananya
    section      chr   e.g. A
    gender       chr   e.g. F
    hours        num   e.g. 9
    marks        num   e.g. 85
    attendance   num   e.g. 92

SUMMARY                        R: summary(df)
                   Min    1stQu   Median     Mean    3rdQu     Max
  hours           2.00     4.25     6.50     6.50     8.75   11.00
  marks          41.00    56.75    71.00    69.10    83.50   91.00
  attendance     60.00    72.00    82.50    80.30    89.50   95.00

MISSING VALUES                 R: colSums(is.na(df))
    name         0
    section      0
    gender       0
    hours        0
    marks        0
    attendance   0

CATEGORICAL COUNTS             R: table(df$section)
    section: A=4, B=3, C=3
    gender: F=6, M=4

HISTOGRAM of marks             R: hist(df$marks)
    40-49  ###### 2
    50-59  ### 1
    60-69  ###### 2
    70-79  ###### 2
    80-89  ###### 2
    90-99  ### 1

OUTLIERS (1.5 x IQR)           R: boxplot(df$marks)$out
    IQR = 26.75, fences = [16.62, 123.62]
    outliers: none

CORRELATION                    R: cor(df[, c('hours','marks')])
    r(hours, marks) = 0.9932

The R script uses R's built-in iris data; the Python equivalent, which has no iris without a package, explores the ten students the other experiments use. The four plots are the histogram, the boxplot by species, the boxplot of all sepal lengths, and the scatterplot matrix.

RESULT

iris has 150 rows and 5 columns, no missing values, and 50 flowers of each species. Petal length and width are the most correlated (0.963). Sepal length's mean, 5.843, is just above its median, 5.8: a slight right skew.

Experiment 6 — Feature engineering

1. Question

Normalise, standardise, encode and bin a column of marks and a column of sections.

2. Aim

Prepare features for modelling in R: scale them, encode categories, and bin a number.

3. Steps

In R, 06_feature_engineering.R:

  1. Enter the marks and sections.
  2. Normalise to [0, 1] by min-max.
  3. Standardise to mean 0 and SD 1.
  4. One-hot encode the sections.
  5. Encode an ordered category, and avoid the factor trap.
  6. Bin the marks into classes.

In Python, python/06_feature_engineering.py:

  1. Normalise by min-max.
  2. Standardise.
  3. One-hot encode.
  4. Bin the marks.
  5. Check the ranges.

R AND PYTHON SCALE DIFFERENTLY

scale() uses sd(), which divides by n−1. scikit-learn's StandardScaler divides by n. The standardised values therefore differ slightly between R and scikit-learn. Harmless for modelling, but do not expect identical numbers, and say so if asked.

4. Programme

In R, 06_feature_engineering.R:

# =====================================================================
# Run with R 4.3.3 (Rscript --vanilla). What it prints, and the plots it
# draws, are on the lab page, and tools/data-science/run_r_equivalents.py
# runs it again. (Until October 2026 R could not be installed where these
# labs are checked, so this file was desk-checked only; every number in its
# comments has since been checked against R's own output.)
# =====================================================================
# Experiment 6: Scaling, normalisation and encoding
# Python equivalent: python/06_feature_engineering.py

# Step 1: Enter the marks and sections
marks <- c(85, 62, 91, 55, 74, 79, 48, 88, 68, 41)
section <- factor(c("A","A","B","B","A","C","C","B","A","C"))

# --- MIN-MAX NORMALISATION: x' = (x - min)/(max - min) -> [0, 1] ---
# Step 2: Normalise to [0, 1] by min-max
normalise <- function(x) (x - min(x)) / (max(x) - min(x))
normalise(marks)
range(normalise(marks))    # must be exactly 0 and 1

# --- STANDARDISATION: x' = (x - mean)/sd -> mean 0, sd 1 ---
# Step 3: Standardise to mean 0 and SD 1
scale(marks)               # returns a matrix; use as.vector() for a vector
as.vector(scale(marks))
mean(scale(marks)); sd(scale(marks))    # ~0 and exactly 1

# NOTE: scale() uses sd(), which divides by n-1. Python's
# sklearn StandardScaler divides by n. The values differ slightly --
# harmless for modelling, but do not expect identical numbers.

# --- ONE-HOT ENCODING ---
# Step 4: One-hot encode the sections
model.matrix(~ section - 1)     # the -1 drops the intercept -> k columns
model.matrix(~ section)         # keeps intercept -> k-1 columns (reference = A)

# For MODELLING use the k-1 form: k columns are perfectly collinear
# (they sum to 1), which is the dummy variable trap. lm() and glm() handle
# factors automatically, so you rarely encode by hand.

# --- LABEL / ORDINAL ENCODING (only for genuinely ordered categories) ---
# Step 5: Encode an ordered category, and avoid the factor trap
sizes <- factor(c("small","large","medium"),
                levels = c("small","medium","large"), ordered = TRUE)
as.numeric(sizes)          # 1 3 2 -- correct here, because the order is real

# THE CLASSIC BUG: as.numeric() on an unordered factor returns LEVEL CODES,
# not the values. For a factor of numbers use:
f <- factor(c("10","20","30"))
as.numeric(f)                    # 1 2 3   <- WRONG
as.numeric(as.character(f))      # 10 20 30 <- correct

# --- BINNING ---
# Step 6: Bin the marks into classes
cut(marks, breaks = c(0, 40, 50, 60, 75, 100),
    labels = c("Fail","Pass","Second","First","Distinction"),
    right = FALSE)
table(cut(marks, breaks = c(0, 40, 50, 60, 75, 100),
          labels = c("Fail","Pass","Second","First","Distinction"),
          right = FALSE))

In Python, python/06_feature_engineering.py:

"""Experiment 6 (Python equivalent) -- scaling, normalisation, encoding.

R version: ../06_feature_engineering.R  (scale(), model.matrix(), cut())
"""
from _shared import STUDENTS


def min_max(v):
    lo, hi = min(v), max(v)
    return [(x - lo) / (hi - lo) for x in v]


def standardise(v, sample=True):
    n = len(v)
    m = sum(v) / n
    div = (n - 1) if sample else n
    sd = (sum((x - m) ** 2 for x in v) / div) ** 0.5
    return [(x - m) / sd for x in v], m, sd


def one_hot(values):
    levels = sorted(set(values))
    return levels, [[1 if v == lv else 0 for lv in levels] for v in values]


def bin_values(v, edges, labels):
    out = []
    for x in v:
        for i in range(len(edges) - 1):
            if edges[i] <= x < edges[i + 1]:
                out.append(labels[i]); break
        else:
            out.append(labels[-1])
    return out


if __name__ == "__main__":
    marks = [r[4] for r in STUDENTS]
    sections = [r[1] for r in STUDENTS]

    # Step 1: Normalise by min-max
    print("MIN-MAX NORMALISATION          x' = (x - min)/(max - min)")
    nm = min_max(marks)
    for m, v in list(zip(marks, nm))[:5]:
        print(f"    {m:>3} -> {v:.4f}")
    print(f"    range check: min={min(nm):.4f}  max={max(nm):.4f}   (must be 0 and 1)")

    # Step 2: Standardise
    print("\nSTANDARDISATION                R: scale(x)  -- uses n-1")
    st, mean, sd = standardise(marks)
    print(f"    mean = {mean:.4f}   sd (n-1) = {sd:.4f}")
    for m, v in list(zip(marks, st))[:5]:
        print(f"    {m:>3} -> {v:+.4f}")
    chk_m = sum(st) / len(st)
    chk_s = (sum((x - chk_m) ** 2 for x in st) / (len(st) - 1)) ** 0.5
    print(f"    check: mean={chk_m:.10f} sd={chk_s:.6f}   (must be 0 and 1)")

    # Step 3: One-hot encode
    print("\nONE-HOT ENCODING               R: model.matrix(~ section - 1)")
    levels, encoded = one_hot(sections)
    print(f"    levels: {levels}")
    for s, e in list(zip(sections, encoded))[:5]:
        print(f"    {s} -> {e}")
    print("    NOTE: for a linear model R drops one level as the reference,")
    print("          giving k-1 columns and avoiding the dummy variable trap.")

    # Step 4: Bin the marks
    print("\nBINNING                        R: cut(marks, breaks = ...)")
    labels = ["Fail", "Pass", "Second", "First", "Distinction"]
    binned = bin_values(marks, [0, 40, 50, 60, 75, 101], labels)
    counts = {}
    for b in binned:
        counts[b] = counts.get(b, 0) + 1
    for lab in labels:
        print(f"    {lab:<12} {counts.get(lab, 0)}")

    # Step 5: Check the ranges
    assert abs(min(nm)) < 1e-12 and abs(max(nm) - 1) < 1e-12
    assert abs(chk_m) < 1e-10 and abs(chk_s - 1) < 1e-10
    print("\n  normalisation spans [0,1] and standardisation gives mean 0, sd 1 ✓")

5. Execution and Results

In R, 06_feature_engineering.R:

OUTPUT

 [1] 0.88 0.42 1.00 0.28 0.66 0.76 0.14 0.94 0.54 0.00
[1] 0 1
             [,1]
 [1,]  0.91851437
 [2,] -0.41015422
 [3,]  1.26512357
 [4,] -0.81453162
 [5,]  0.28306418
 [6,]  0.57190518
 [7,] -1.21890901
 [8,]  1.09181897
 [9,] -0.06354502
[10,] -1.62328641
attr(,"scaled:center")
[1] 69.1
attr(,"scaled:scale")
[1] 17.31056
 [1]  0.91851437 -0.41015422  1.26512357 -0.81453162  0.28306418  0.57190518
 [7] -1.21890901  1.09181897 -0.06354502 -1.62328641
[1] 3.496986e-16
[1] 1
   sectionA sectionB sectionC
1         1        0        0
2         1        0        0
3         0        1        0
4         0        1        0
5         1        0        0
6         0        0        1
7         0        0        1
8         0        1        0
9         1        0        0
10        0        0        1
attr(,"assign")
[1] 1 1 1
attr(,"contrasts")
attr(,"contrasts")$section
[1] "contr.treatment"

   (Intercept) sectionB sectionC
1            1        0        0
2            1        0        0
3            1        1        0
4            1        1        0
5            1        0        0
6            1        0        1
7            1        0        1
8            1        1        0
9            1        0        0
10           1        0        1
attr(,"assign")
[1] 0 1 1
attr(,"contrasts")
attr(,"contrasts")$section
[1] "contr.treatment"

[1] 1 3 2
[1] 1 2 3
[1] 10 20 30
 [1] Distinction First       Distinction Second      First       Distinction
 [7] Pass        Distinction First       Pass
Levels: Fail Pass Second First Distinction

       Fail        Pass      Second       First Distinction
          0           2           1           3           4

In Python, python/06_feature_engineering.py:

OUTPUT

MIN-MAX NORMALISATION          x' = (x - min)/(max - min)
     85 -> 0.8800
     62 -> 0.4200
     91 -> 1.0000
     55 -> 0.2800
     74 -> 0.6600
    range check: min=0.0000  max=1.0000   (must be 0 and 1)

STANDARDISATION                R: scale(x)  -- uses n-1
    mean = 69.1000   sd (n-1) = 17.3106
     85 -> +0.9185
     62 -> -0.4102
     91 -> +1.2651
     55 -> -0.8145
     74 -> +0.2831
    check: mean=0.0000000000 sd=1.000000   (must be 0 and 1)

ONE-HOT ENCODING               R: model.matrix(~ section - 1)
    levels: ['A', 'B', 'C']
    A -> [1, 0, 0]
    A -> [1, 0, 0]
    B -> [0, 1, 0]
    B -> [0, 1, 0]
    A -> [1, 0, 0]
    NOTE: for a linear model R drops one level as the reference,
          giving k-1 columns and avoiding the dummy variable trap.

BINNING                        R: cut(marks, breaks = ...)
    Fail         0
    Pass         2
    Second       1
    First        3
    Distinction  4

  normalisation spans [0,1] and standardisation gives mean 0, sd 1 ✓

Corrected: this note said the standardised values differ between the R script and its Python equivalent. The Python equivalent divides by n − 1, as R does, so they agree: 85 becomes 0.9185 in both. It is scikit-learn's StandardScaler that would differ.

RESULT

Min-max puts the marks in [0, 1]; standardising gives mean 0 and SD 1 (R's mean is 3.5 × 10⁻¹⁶, zero to rounding). One-hot encoding gives three columns, or two with an intercept, and binning gives Pass 2, Second 1, First 3 and Distinction 4.

Experiment 7 — Variables, control structures, functions

1. Question

Use R's variable types, vectors, control structures, apply family and functions.

2. Aim

Write R's basic constructs, and see where R differs from other languages.

3. Steps

  1. Make a variable of each type.
  2. Index and compute on vectors.
  3. Branch with if and ifelse().
  4. Loop with for, while and repeat.
  5. Apply a function across a matrix or a list.
  6. Write functions, with default and variadic arguments.

4. Programme

# =====================================================================
# Run with R 4.3.3 (Rscript --vanilla). What it prints, and the plots it
# draws, are on the lab page, and tools/data-science/run_r_equivalents.py
# runs it again. (Until October 2026 R could not be installed where these
# labs are checked, so this file was desk-checked only; every number in its
# comments has since been checked against R's own output.)
# =====================================================================
# Experiment 7: Variables, control structures and functions
# No Python equivalent -- this experiment demonstrates R SYNTAX, so a
# translation would teach nothing.

# --- VARIABLES AND TYPES ---
# Step 1: Make a variable of each type
x <- 10L          # integer (note the L)
y <- 3.14         # numeric / double
name <- "Ravi"    # character
flag <- TRUE      # logical
z <- 3 + 4i       # complex

class(x); class(y); class(name); class(flag)
# NOTE: class(10) is "numeric", NOT "integer". Write 10L for an integer.

# --- VECTORS: R's fundamental unit ---
# Step 2: Index and compute on vectors
v <- c(10, 20, 30, 40, 50)
v[1]        # 10  -- R indexes from 1, not 0
v[-1]       # 20 30 40 50  -- negative EXCLUDES; it is not "from the end"
v[v > 25]   # 30 40 50
length(v)

v * 2       # 20 40 60 80 100  -- VECTORISED, no loop needed
c(1,2,3,4) + c(10,20)   # 11 22 13 24 -- RECYCLING of the shorter vector

# --- CONTROL STRUCTURES ---
# Step 3: Branch with if and ifelse()
marks <- 72
if (marks >= 40) {
  print("Pass")
} else if (marks >= 30) {
  print("Supplementary")
} else {
  print("Fail")
}

# ifelse() is VECTORISED -- use it on a whole column, never if()
ifelse(v > 25, "high", "low")

# Step 4: Loop with for, while and repeat
for (i in 1:5) print(i)
for (nm in c("A", "B", "C")) print(nm)

i <- 1
while (i <= 5) { print(i); i <- i + 1 }

repeat { i <- i + 1; if (i > 10) break }

# --- THE apply FAMILY: R's idiomatic alternative to loops ---
# Step 5: Apply a function across a matrix or a list
m <- matrix(1:6, nrow = 2)
apply(m, 1, sum)     # row sums     -- MARGIN 1 = rows
apply(m, 2, mean)    # column means -- MARGIN 2 = columns
sapply(1:5, function(k) k^2)          # 1 4 9 16 25 -- returns a vector
lapply(1:3, function(k) k^2)          # returns a LIST

# --- FUNCTIONS ---
# Step 6: Write functions, with default and variadic arguments
grade <- function(marks, pass_mark = 40) {
  if (marks >= 90) return("A")
  if (marks >= 75) return("B")
  if (marks >= 60) return("C")
  if (marks >= pass_mark) return("D")
  "F"                      # the last expression is returned automatically
}
grade(85)                  # "B"
grade(35, pass_mark = 30)  # "D" -- named argument

total <- function(...) sum(...)     # variadic
total(1, 2, 3, 4)                   # 10

5. Execution and Results

OUTPUT

[1] "integer"
[1] "numeric"
[1] "character"
[1] "logical"
[1] 10
[1] 20 30 40 50
[1] 30 40 50
[1] 5
[1]  20  40  60  80 100
[1] 11 22 13 24
[1] "Pass"
[1] "low"  "low"  "high" "high" "high"
[1] 1
[1] 2
[1] 3
[1] 4
[1] 5
[1] "A"
[1] "B"
[1] "C"
[1] 1
[1] 2
[1] 3
[1] 4
[1] 5
[1]  9 12
[1] 1.5 3.5 5.5
[1]  1  4  9 16 25
[[1]]
[1] 1

[[2]]
[1] 4

[[3]]
[1] 9

[1] "B"
[1] "D"
[1] 10

Two of R's habits show in the output: indexing starts at 1, so v[1] is 10, and v[-1] leaves the first element out rather than taking the last.

RESULT

The vector operations, branches, loops and functions all behave as their comments say: grade(85) is "B", and total(1, 2, 3, 4) is 10.

Experiment 8 — CSV, Excel, JSON, XML

1. Question

Read and write data as CSV, Excel, JSON and XML files.

2. Aim

Move a data frame in and out of R in each common file format.

3. Steps

In R, 08_file_io.R:

  1. Make a small data frame.
  2. Write and read CSV.
  3. Load the Excel packages.
  4. Write and read JSON.
  5. Load the XML package.
  6. Save and load R's own formats.

In Python, python/08_file_io.py:

  1. Write and read back CSV, JSON and XML.
  2. Compare the types that come back.

4. Programme

In R, 08_file_io.R:

# =====================================================================
# Run with R 4.3.3 (Rscript --vanilla). What it prints, and the plots it
# draws, are on the lab page, and tools/data-science/run_r_equivalents.py
# runs it again. (Until October 2026 R could not be installed where these
# labs are checked, so this file was desk-checked only; every number in its
# comments has since been checked against R's own output.)
# =====================================================================
# Experiment 8: Read and write CSV, Excel, JSON and XML
# Python equivalent: python/08_file_io.py

# Step 1: Make a small data frame
df <- data.frame(name = c("Ananya","Charan","Divya"),
                 section = c("A","B","B"),
                 marks = c(85, 91, 55),
                 stringsAsFactors = FALSE)

# --- CSV ---
# Step 2: Write and read CSV
write.csv(df, "students.csv", row.names = FALSE)
back <- read.csv("students.csv", stringsAsFactors = FALSE)
# row.names = FALSE matters: without it R writes an extra index column and
# re-reading gives you a stray "X" column you did not ask for.

library(readr)                     # faster; returns a tibble; never factorises
write_csv(df, "students2.csv"); read_csv("students2.csv")

# --- EXCEL ---
# Step 3: Load the Excel packages
library(readxl)
# read_excel("students.xlsx", sheet = 1)
# excel_sheets("students.xlsx")    # list the sheet names first
library(writexl)
# write_xlsx(df, "students.xlsx")

# --- JSON ---
# Step 4: Write and read JSON
library(jsonlite)
write_json(df, "students.json", pretty = TRUE)
fromJSON("students.json")          # comes back as a data frame directly
toJSON(df, pretty = TRUE, auto_unbox = TRUE)
# JSON preserves TYPES -- numbers come back as numbers. CSV and XML do not.

# --- XML ---
# Step 5: Load the XML package
library(XML)
# doc <- xmlParse("students.xml")
# xmlToDataFrame(doc)
# Alternative, often easier: library(xml2); read_xml(); xml_find_all()

# --- R's own formats ---
# Step 6: Save and load R's own formats
saveRDS(df, "students.rds"); readRDS("students.rds")   # ONE object
save(df, file = "students.RData"); load("students.RData")  # several, by name

# saveRDS/readRDS is preferred: you choose the variable name on load.
# load() silently overwrites whatever names were saved.

In Python, python/08_file_io.py:

"""Experiment 8 (Python equivalent) -- read/write CSV, JSON, XML.

R version: ../08_file_io.R  (read.csv, jsonlite, XML, readxl)
Excel is omitted here: writing .xlsx needs a third-party library, and the point
of the experiment -- that each format round-trips -- is made by the other three.
"""
import csv, json, tempfile, pathlib
import xml.etree.ElementTree as ET
from _shared import STUDENTS

COLS = ["name", "section", "gender", "hours", "marks", "attendance"]
ROWS = [dict(zip(COLS, r)) for r in STUDENTS]


def csv_roundtrip(d):
    p = d / "students.csv"
    with open(p, "w", newline="") as fh:
        w = csv.DictWriter(fh, fieldnames=COLS); w.writeheader(); w.writerows(ROWS)
    with open(p, newline="") as fh:
        back = list(csv.DictReader(fh))
    # everything read from CSV is a string -- the same trap as in Course 3
    return back, all(isinstance(v, str) for v in back[0].values())


def json_roundtrip(d):
    p = d / "students.json"
    p.write_text(json.dumps(ROWS, indent=2))
    back = json.loads(p.read_text())
    return back, isinstance(back[0]["marks"], int)      # JSON preserves types


def xml_roundtrip(d):
    p = d / "students.xml"
    root = ET.Element("students")
    for r in ROWS:
        el = ET.SubElement(root, "student")
        for k, v in r.items():
            ET.SubElement(el, k).text = str(v)
    ET.ElementTree(root).write(p, encoding="utf-8", xml_declaration=True)
    back = [{c.tag: c.text for c in el} for el in ET.parse(p).getroot()]
    return back, all(isinstance(v, str) for v in back[0].values())


if __name__ == "__main__":
    # Step 1: Write and read back CSV, JSON and XML
    with tempfile.TemporaryDirectory() as td:
        d = pathlib.Path(td)
        for name, fn in (("CSV", csv_roundtrip), ("JSON", json_roundtrip),
                         ("XML", xml_roundtrip)):
            back, typed = fn(d)
            same = len(back) == len(ROWS) and back[0]["name"] == ROWS[0]["name"]
            print(f"{name:<6} wrote {len(ROWS)} rows, read {len(back)} back  "
                  f"-> {'round-trips ✓' if same else 'MISMATCH'}")
            if name == "JSON":
                print(f"       numeric types preserved: {typed}")
            else:
                print(f"       all values come back as strings: {typed}")

    # Step 2: Compare the types that come back
    print("\n  KEY POINT: CSV and XML are untyped -- everything returns as text,")
    print("  so numbers must be converted explicitly. JSON preserves numbers and")
    print("  booleans. In R this is why read.csv() has colClasses= and why")
    print("  jsonlite::fromJSON() gives you a usable data frame directly.")

5. Execution and Results

In R, 08_file_io.R:

OUTPUT

Rows: 3 Columns: 3
── Column specification ────────────────────────────────────────────────────────
Delimiter: ","
chr (2): name, section
dbl (1): marks

ℹ Use `spec()` to retrieve the full column specification for this data.
ℹ Specify the column types or set `show_col_types = FALSE` to quiet this message.
# A tibble: 3 × 3
  name   section marks
  <chr>  <chr>   <dbl>
1 Ananya A          85
2 Charan B          91
3 Divya  B          55
    name section marks
1 Ananya       A    85
2 Charan       B    91
3  Divya       B    55
[
  {
    "name": "Ananya",
    "section": "A",
    "marks": 85
  },
  {
    "name": "Charan",
    "section": "B",
    "marks": 91
  },
  {
    "name": "Divya",
    "section": "B",
    "marks": 55
  }
]
    name section marks
1 Ananya       A    85
2 Charan       B    91
3  Divya       B    55

In Python, python/08_file_io.py:

OUTPUT

CSV    wrote 10 rows, read 10 back  -> round-trips ✓
       all values come back as strings: True
JSON   wrote 10 rows, read 10 back  -> round-trips ✓
       numeric types preserved: True
XML    wrote 10 rows, read 10 back  -> round-trips ✓
       all values come back as strings: True

  KEY POINT: CSV and XML are untyped -- everything returns as text,
  so numbers must be converted explicitly. JSON preserves numbers and
  booleans. In R this is why read.csv() has colClasses= and why
  jsonlite::fromJSON() gives you a usable data frame directly.

The Excel and XML reads and writes are comments in the file, as the steps say: only their packages are loaded. JSON brings the marks back as numbers; CSV and XML bring everything back as text, as the Python equivalent shows.

RESULT

The data frame is written and read back unchanged as CSV, JSON and R's own formats.

Experiment 9 — dplyr and tidyr

1. Question

Filter, select, create, sort, summarise, join and reshape a table of students.

2. Aim

Wrangle data with dplyr's verbs and tidyr's pivots.

3. Steps

In R, 09_wrangling.R:

  1. Make the students data frame.
  2. Filter, select, mutate and arrange in one pipe.
  3. Summarise by section.
  4. Count, find distinct values, take the top three, rename.
  5. Join to the teachers table.
  6. Reshape from wide to long and back.

In Python, python/09_wrangling.py:

  1. Filter, select, mutate and arrange.
  2. Group and summarise.
  3. Pivot longer and wider.
  4. Check the shapes.

4. Programme

In R, 09_wrangling.R:

# =====================================================================
# Run with R 4.3.3 (Rscript --vanilla). What it prints, and the plots it
# draws, are on the lab page, and tools/data-science/run_r_equivalents.py
# runs it again. (Until October 2026 R could not be installed where these
# labs are checked, so this file was desk-checked only; every number in its
# comments has since been checked against R's own output.)
# =====================================================================
# Experiment 9: Data wrangling with dplyr and tidyr
# Python equivalent: python/09_wrangling.py (annotated with these dplyr calls)

library(dplyr); library(tidyr)

# Step 1: Make the students data frame
students <- data.frame(
  name    = c("Ananya","Bhavana","Charan","Divya","Eshwar",
              "Fiona","Gopal","Harika","Ismail","Jyothi"),
  section = c("A","A","B","B","A","C","C","B","A","C"),
  hours   = c(9, 5, 11, 4, 7, 8, 3, 10, 6, 2),
  marks   = c(85, 62, 91, 55, 74, 79, 48, 88, 68, 41),
  stringsAsFactors = FALSE
)

# --- THE FIVE VERBS, chained with the pipe ---
# Step 2: Filter, select, mutate and arrange in one pipe
students %>%
  filter(marks > 60) %>%                    # rows      -- SQL WHERE
  select(name, section, marks) %>%          # columns   -- SQL SELECT
  mutate(grade = case_when(                 # new column
    marks >= 90 ~ "A",
    marks >= 75 ~ "B",
    marks >= 60 ~ "C",
    TRUE        ~ "F")) %>%                 # TRUE ~ is the else branch
  arrange(desc(marks))                      # sort      -- SQL ORDER BY

# --- GROUPED SUMMARY -- SQL's GROUP BY ---
# Step 3: Summarise by section
students %>%
  group_by(section) %>%
  summarise(n        = n(),
            avg      = mean(marks),
            highest  = max(marks),
            pass_pct = mean(marks >= 40) * 100,   # mean of a logical!
            .groups  = "drop") %>%
  arrange(desc(avg))

# mean() of a logical vector gives a PROPORTION, because TRUE counts as 1.
# .groups = "drop" ungroups the result -- omit it and later operations
# silently stay grouped, which is a common source of confusion.

# --- USEFUL EXTRAS ---
# Step 4: Count, find distinct values, take the top three, rename
students %>% count(section)
students %>% distinct(section)
students %>% slice_max(marks, n = 3)
students %>% rename(score = marks)

# --- JOINS: the same seven as Course 5 ---
# Step 5: Join to the teachers table
sections <- data.frame(section = c("A","B","C","D"),
                       teacher = c("Rao","Devi","Kumar","Reddy"))
inner_join(students, sections, by = "section")
left_join (students, sections, by = "section")
anti_join (sections, students, by = "section")   # section D has no students

# --- RESHAPING with tidyr ---
# Step 6: Reshape from wide to long and back
wide <- data.frame(name = c("A","B"), maths = c(85,72),
                   science = c(78,88), english = c(92,65))

long <- wide %>%
  pivot_longer(cols = c(maths, science, english),
               names_to = "subject", values_to = "marks")
long           # 6 rows: one per student-subject pair

long %>% pivot_wider(names_from = subject, values_from = marks)   # back again

# ggplot2 WANTS LONG DATA. That is the practical reason this matters:
# to draw one bar per subject, subject must be a COLUMN, not three columns.

In Python, python/09_wrangling.py:

"""Experiment 9 (Python equivalent) -- dplyr/tidyr operations in pandas.

R version: ../09_wrangling.R
Every pandas call is annotated with its dplyr counterpart, which is the point
of this file: the two libraries do the same six things.
"""
import pandas as pd
from _shared import STUDENTS

COLS = ["name", "section", "gender", "hours", "marks", "attendance"]
df = pd.DataFrame(STUDENTS, columns=COLS)

if __name__ == "__main__":
    pd.set_option("display.width", 100)

    # Step 1: Filter, select, mutate and arrange
    print("filter()   dplyr: filter(df, marks > 70)")
    print(df[df.marks > 70][["name", "section", "marks"]].to_string(index=False))

    print("\nselect()   dplyr: select(df, name, marks)")
    print(df[["name", "marks"]].head(3).to_string(index=False))

    print("\nmutate()   dplyr: mutate(df, grade = case_when(...))")
    df["grade"] = pd.cut(df.marks, bins=[0, 40, 60, 75, 90, 101],
                         labels=["F", "D", "C", "B", "A"], right=False)
    print(df[["name", "marks", "grade"]].head(5).to_string(index=False))

    print("\narrange()  dplyr: arrange(df, desc(marks))")
    print(df.sort_values("marks", ascending=False)[["name", "marks"]]
            .head(3).to_string(index=False))

    # Step 2: Group and summarise
    print("\ngroup_by + summarise")
    print("  dplyr: group_by(section) %>% summarise(n=n(), avg=mean(marks))")
    g = (df.groupby("section")
           .agg(n=("marks", "size"), avg=("marks", "mean"),
                highest=("marks", "max"),
                pass_pct=("marks", lambda s: (s >= 40).mean() * 100))
           .reset_index())
    print(g.to_string(index=False))

    # Step 3: Pivot longer and wider
    print("\npivot_longer()  tidyr: pivot_longer(cols = c(hours, marks))")
    long = df.melt(id_vars=["name", "section"], value_vars=["hours", "marks"],
                   var_name="measure", value_name="value")
    print(long.head(4).to_string(index=False))
    print(f"  wide {df.shape} -> long {long.shape}")

    print("\npivot_wider()   tidyr: pivot_wider(names_from, values_from)")
    wide = long.pivot_table(index=["name", "section"], columns="measure",
                            values="value").reset_index()
    print(wide.head(3).to_string(index=False))

    # Step 4: Check the shapes
    assert len(long) == len(df) * 2, "melt must double the rows for two measures"
    assert set(g.section) == {"A", "B", "C"}
    print("\n  long form has 2x the rows, as pivot_longer would produce ✓")

5. Execution and Results

In R, 09_wrangling.R:

OUTPUT


Attaching package: ‘dplyr’

The following objects are masked from ‘package:stats’:

    filter, lag

The following objects are masked from ‘package:base’:

    intersect, setdiff, setequal, union

     name section marks grade
1  Charan       B    91     A
2  Harika       B    88     B
3  Ananya       A    85     B
4   Fiona       C    79     B
5  Eshwar       A    74     C
6  Ismail       A    68     C
7 Bhavana       A    62     C
# A tibble: 3 × 5
  section     n   avg highest pass_pct
  <chr>   <int> <dbl>   <dbl>    <dbl>
1 B           3  78        91      100
2 A           4  72.2      85      100
3 C           3  56        79      100
  section n
1       A 4
2       B 3
3       C 3
  section
1       A
2       B
3       C
    name section hours marks
1 Charan       B    11    91
2 Harika       B    10    88
3 Ananya       A     9    85
      name section hours score
1   Ananya       A     9    85
2  Bhavana       A     5    62
3   Charan       B    11    91
4    Divya       B     4    55
5   Eshwar       A     7    74
6    Fiona       C     8    79
7    Gopal       C     3    48
8   Harika       B    10    88
9   Ismail       A     6    68
10  Jyothi       C     2    41
      name section hours marks teacher
1   Ananya       A     9    85     Rao
2  Bhavana       A     5    62     Rao
3   Charan       B    11    91    Devi
4    Divya       B     4    55    Devi
5   Eshwar       A     7    74     Rao
6    Fiona       C     8    79   Kumar
7    Gopal       C     3    48   Kumar
8   Harika       B    10    88    Devi
9   Ismail       A     6    68     Rao
10  Jyothi       C     2    41   Kumar
      name section hours marks teacher
1   Ananya       A     9    85     Rao
2  Bhavana       A     5    62     Rao
3   Charan       B    11    91    Devi
4    Divya       B     4    55    Devi
5   Eshwar       A     7    74     Rao
6    Fiona       C     8    79   Kumar
7    Gopal       C     3    48   Kumar
8   Harika       B    10    88    Devi
9   Ismail       A     6    68     Rao
10  Jyothi       C     2    41   Kumar
  section teacher
1       D   Reddy
# A tibble: 6 × 3
  name  subject marks
  <chr> <chr>   <dbl>
1 A     maths      85
2 A     science    78
3 A     english    92
4 B     maths      72
5 B     science    88
6 B     english    65
# A tibble: 2 × 4
  name  maths science english
  <chr> <dbl>   <dbl>   <dbl>
1 A        85      78      92
2 B        72      88      65

In Python, python/09_wrangling.py:

OUTPUT

filter()   dplyr: filter(df, marks > 70)
  name section  marks
Ananya       A     85
Charan       B     91
Eshwar       A     74
 Fiona       C     79
Harika       B     88

select()   dplyr: select(df, name, marks)
   name  marks
 Ananya     85
Bhavana     62
 Charan     91

mutate()   dplyr: mutate(df, grade = case_when(...))
   name  marks grade
 Ananya     85     B
Bhavana     62     C
 Charan     91     A
  Divya     55     D
 Eshwar     74     C

arrange()  dplyr: arrange(df, desc(marks))
  name  marks
Charan     91
Harika     88
Ananya     85

group_by + summarise
  dplyr: group_by(section) %>% summarise(n=n(), avg=mean(marks))
section  n   avg  highest  pass_pct
      A  4 72.25       85     100.0
      B  3 78.00       91     100.0
      C  3 56.00       79     100.0

pivot_longer()  tidyr: pivot_longer(cols = c(hours, marks))
   name section measure  value
 Ananya       A   hours      9
Bhavana       A   hours      5
 Charan       B   hours     11
  Divya       B   hours      4
  wide (10, 7) -> long (20, 4)

pivot_wider()   tidyr: pivot_wider(names_from, values_from)
   name section  hours  marks
 Ananya       A    9.0   85.0
Bhavana       A    5.0   62.0
 Charan       B   11.0   91.0

  long form has 2x the rows, as pivot_longer would produce ✓

RESULT

Seven students scored over 60; section B has the highest average, 78. Section D has no students, so anti_join() returns it, and the wide table of 2 students becomes 6 rows when made long.

Experiment 10 — Missing data and outliers

1. Question

Find the missing values and the outliers in a column of marks, and handle them.

2. Aim

Detect and impute missing values, and find outliers by the IQR and z-score rules.

3. Steps

In R, 10_missing_outliers.R:

  1. Enter the data, with missing values and an outlier.
  2. Find the missing values.
  3. Impute them by the mean or the median.
  4. Find outliers by the IQR rule.
  5. Find outliers by the z-score rule.
  6. Add a second outlier, and see masking.

In Python, python/10_missing_outliers.py:

  1. Find the missing values.
  2. Compare mean and median imputation.
  3. Find outliers by the IQR rule.
  4. Find outliers by the z-score rule.
  5. Add a second outlier, and see masking.

MASKING, WHICH THE LAB ACTUALLY DEMONSTRATES

Both the IQR and z-score rules catch a single outlier of 250. Add a second at 260 and the standard deviation inflates from 46.40 to 61.97 — enough that the z-score rule flags nothing, while the IQR rule still catches both.

That is masking, and it is why the IQR rule is preferred when outliers may cluster. The Python equivalent asserts this, so the claim is tested rather than asserted.

4. Programme

In R, 10_missing_outliers.R:

# =====================================================================
# Run with R 4.3.3 (Rscript --vanilla). What it prints, and the plots it
# draws, are on the lab page, and tools/data-science/run_r_equivalents.py
# runs it again. (Until October 2026 R could not be installed where these
# labs are checked, so this file was desk-checked only; every number in its
# comments has since been checked against R's own output.)
# =====================================================================
# Experiment 10: Handle missing data and detect outliers
# Python equivalent: python/10_missing_outliers.py

# Step 1: Enter the data, with missing values and an outlier
x <- c(45, 67, NA, 52, 89, 91, NA, 64, 58, 82,
       76, 69, 71, 250, 60, 55, 93, 48, 79, NA)   # 250 is a planted outlier

# --- DETECTING MISSING VALUES ---
# Step 2: Find the missing values
is.na(x)              # logical vector
sum(is.na(x))         # 3
which(is.na(x))       # 3 7 20  -- positions, 1-based
mean(is.na(x)) * 100  # 15% missing

# NEVER write x == NA. Comparing with an unknown value yields NA, never TRUE.
# This is the same trap as SQL's "= NULL" from Course 5.

# --- HANDLING ---
# Step 3: Impute them by the mean or the median
mean(x, na.rm = TRUE)          # 79.35 -- drag upward from the 250
median(x, na.rm = TRUE)        # 69.00 -- resistant
# [Corrected: these said 79.06 and 68.00; R and the Python version both give
# 79.35 and 69.00.]
clean <- na.omit(x)            # drop the NAs

x_mean_imputed   <- ifelse(is.na(x), mean(x, na.rm = TRUE), x)
x_median_imputed <- ifelse(is.na(x), median(x, na.rm = TRUE), x)
# With an outlier present, MEDIAN imputation is the safer choice -- the mean
# has already been distorted by the very value you are trying to work around.

# --- OUTLIERS: the IQR rule ---
# Step 4: Find outliers by the IQR rule
q <- quantile(clean, c(0.25, 0.75))
iqr <- IQR(clean)
lower <- q[1] - 1.5 * iqr        # 22.00
upper <- q[2] + 1.5 * iqr        # 118.00
clean[clean < lower | clean > upper]     # 250
boxplot(clean)$out                       # same answer, drawn

# --- OUTLIERS: the z-score rule ---
# Step 5: Find outliers by the z-score rule
z <- (clean - mean(clean)) / sd(clean)
clean[abs(z) > 3]                # 250 here, z = 3.677

# MASKING -- why the IQR rule is preferred when outliers may cluster:
# add a second extreme value and the sd inflates enough that NEITHER is
# flagged by the z-score rule, while the IQR rule still catches both.
# Step 6: Add a second outlier, and see masking
masked <- c(clean, 260)
zm <- (masked - mean(masked)) / sd(masked)
masked[abs(zm) > 3]              # returns NOTHING -- both outliers masked
qm <- quantile(masked, c(0.25, 0.75)); im <- IQR(masked)
masked[masked < qm[1] - 1.5*im | masked > qm[2] + 1.5*im]   # 250 260 -- caught

In Python, python/10_missing_outliers.py:

"""Experiment 10 (Python equivalent) -- missing data and outlier detection.

R version: ../10_missing_outliers.R  (is.na, na.omit, boxplot$out)
"""
import statistics

RAW = [45, 67, None, 52, 89, 91, None, 64, 58, 82,
       76, 69, 71, 250, 60, 55, 93, 48, 79, None]      # 250 is an outlier


def detect(v):
    missing = [i for i, x in enumerate(v) if x is None]
    return missing, [x for x in v if x is not None]


def iqr_fences(v):
    s = sorted(v); n = len(s)
    def q(p):
        pos = (n - 1) * p; lo = int(pos); hi = min(lo + 1, n - 1)
        return s[lo] + (pos - lo) * (s[hi] - s[lo])
    q1, q3 = q(.25), q(.75)
    iqr = q3 - q1
    return q1, q3, iqr, q1 - 1.5 * iqr, q3 + 1.5 * iqr


def zscore_outliers(v, threshold=3):
    m = statistics.mean(v); sd = statistics.stdev(v)
    return [x for x in v if abs((x - m) / sd) > threshold]


if __name__ == "__main__":
    missing, clean = detect(RAW)
    # Step 1: Find the missing values
    print("MISSING VALUES                 R: sum(is.na(x)) ; which(is.na(x))")
    print(f"  {len(missing)} of {len(RAW)} missing ({len(missing)/len(RAW):.0%})")
    print(f"  at positions (1-based, as R reports): {[i+1 for i in missing]}")

    # Step 2: Compare mean and median imputation
    print("\nIMPUTATION strategies")
    mean_i = statistics.mean(clean)
    med_i = statistics.median(clean)
    print(f"  mean imputation   -> fill with {mean_i:.2f}")
    print(f"  median imputation -> fill with {med_i:.2f}")
    print(f"  the two differ by {abs(mean_i-med_i):.2f} because the 250 drags the mean")
    print("  -> with an outlier present, MEDIAN imputation is the safer choice")

    # Step 3: Find outliers by the IQR rule
    print("\nOUTLIERS -- IQR rule           R: boxplot(x)$out")
    q1, q3, iqr, lo, hi = iqr_fences(clean)
    out_iqr = [x for x in clean if x < lo or x > hi]
    print(f"  Q1={q1:.2f}  Q3={q3:.2f}  IQR={iqr:.2f}")
    print(f"  fences = [{lo:.2f}, {hi:.2f}]")
    print(f"  outliers: {out_iqr}")

    # Step 4: Find outliers by the z-score rule
    print("\nOUTLIERS -- z-score rule (|z| > 3)")
    out_z = zscore_outliers(clean)
    print(f"  outliers: {out_z if out_z else 'none'}")
    m, sd = statistics.mean(clean), statistics.stdev(clean)
    print(f"  z for 250 = {(250-m)/sd:.3f}")

    # Step 5: Add a second outlier, and see masking
    print("\n  Both rules caught it here. Now MASKING, with a second outlier:")
    masked = clean + [260]
    m2, sd2 = statistics.mean(masked), statistics.stdev(masked)
    z_flagged = [x for x in masked if abs((x - m2) / sd2) > 3]
    q1b, q3b, iqrb, lob, hib = iqr_fences(masked)
    iqr_flagged = [x for x in masked if x < lob or x > hib]
    print(f"    with 250 AND 260 present: sd rises from {sd:.2f} to {sd2:.2f}")
    print(f"    z-score rule flags: {z_flagged if z_flagged else 'NOTHING'}")
    print(f"    IQR rule flags:     {iqr_flagged}")
    print("\n  That is masking: each outlier inflates the sd enough to pull the")
    print("  other back inside 3 standard deviations, so the z-score rule sees")
    print("  neither. Quartiles cannot be moved by extreme values, so the IQR")
    print("  rule still flags both. Prefer the IQR rule when outliers may cluster.")

    assert 250 in out_iqr, "IQR rule must catch the planted outlier"
    assert len(iqr_flagged) == 2, "IQR rule must catch both"
    assert len(z_flagged) < 2, "z-score rule should be masked by the pair"
    print("\n  IQR caught both; the z-score rule was masked ✓")

5. Execution and Results

In R, 10_missing_outliers.R:

OUTPUT

 [1] FALSE FALSE  TRUE FALSE FALSE FALSE  TRUE FALSE FALSE FALSE FALSE FALSE
[13] FALSE FALSE FALSE FALSE FALSE FALSE FALSE  TRUE
[1] 3
[1]  3  7 20
[1] 15
[1] 79.35294
[1] 69
[1] 250
[1] 250
[1] 250
numeric(0)
[1] 250 260

10_missing_outliers.R: chart 1 of 1, drawn by the program

In Python, python/10_missing_outliers.py:

OUTPUT

MISSING VALUES                 R: sum(is.na(x)) ; which(is.na(x))
  3 of 20 missing (15%)
  at positions (1-based, as R reports): [3, 7, 20]

IMPUTATION strategies
  mean imputation   -> fill with 79.35
  median imputation -> fill with 69.00
  the two differ by 10.35 because the 250 drags the mean
  -> with an outlier present, MEDIAN imputation is the safer choice

OUTLIERS -- IQR rule           R: boxplot(x)$out
  Q1=58.00  Q3=82.00  IQR=24.00
  fences = [22.00, 118.00]
  outliers: [250]

OUTLIERS -- z-score rule (|z| > 3)
  outliers: [250]
  z for 250 = 3.677

  Both rules caught it here. Now MASKING, with a second outlier:
    with 250 AND 260 present: sd rises from 46.40 to 61.97
    z-score rule flags: NOTHING
    IQR rule flags:     [250, 260]

  That is masking: each outlier inflates the sd enough to pull the
  other back inside 3 standard deviations, so the z-score rule sees
  neither. Quartiles cannot be moved by extreme values, so the IQR
  rule still flags both. Prefer the IQR rule when outliers may cluster.

  IQR caught both; the z-score rule was masked ✓

Corrected: the comments gave the mean and median, without the missing values, as 79.06 and 68.00. R and the Python equivalent both give 79.35 and 69.00.

RESULT

3 of the 20 values (15%) are missing. Median imputation, 69, is the safer choice, as the 250 pulls the mean up to 79.35. Both rules find the 250 (fences 22 and 118; z = 3.677); with a second outlier, only the IQR rule finds them.

Experiment 11 — Dates and times

1. Question

Parse, format, take apart and do arithmetic on dates, and sort them.

2. Aim

Handle dates in base R and with lubridate, and see why dates must not be stored as text.

3. Steps

In R, 11_dates.R:

  1. Make dates in base R, and format them.
  2. Do arithmetic on dates.
  3. Do the same with lubridate.
  4. Sort dates as text and as dates.

In Python, python/11_dates.py:

  1. Parse the dates.
  2. Take a date apart.
  3. Do arithmetic on dates.
  4. Format them.
  5. Sort dates as text and as dates.

4. Programme

In R, 11_dates.R:

# =====================================================================
# Run with R 4.3.3 (Rscript --vanilla). What it prints, and the plots it
# draws, are on the lab page, and tools/data-science/run_r_equivalents.py
# runs it again. (Until October 2026 R could not be installed where these
# labs are checked, so this file was desk-checked only; every number in its
# comments has since been checked against R's own output.)
# =====================================================================
# Experiment 11: Working with dates and times
# Python equivalent: python/11_dates.py

# --- BASE R ---
# Step 1: Make dates in base R, and format them
d <- as.Date("2026-08-26")                       # ISO is the safe default
d2 <- as.Date("26/08/2026", format = "%d/%m/%Y")
Sys.Date(); Sys.time()

format(d, "%d-%m-%Y")      # "26-08-2026"
format(d, "%d %B %Y")      # "26 August 2026"
weekdays(d); months(d)

# Step 2: Do arithmetic on dates
d + 30                     # date arithmetic works directly
difftime(as.Date("2026-12-25"), d, units = "days")   # 121 days

# --- lubridate: much easier ---
# Step 3: Do the same with lubridate
library(lubridate)
ymd("2026-08-26"); dmy("26-08-2026"); mdy("08-26-2026")
year(d); month(d); day(d); wday(d, label = TRUE)
d + days(30); d + months(1); d + years(1)
d %m+% months(1)     # SAFE month addition
# 31 Jan %m+% months(1) gives 28 Feb, not an invalid 31 Feb.

# --- WHY DATES MUST NOT BE STORED AS TEXT ---
# Step 4: Sort dates as text and as dates
as_text <- c("10/01/2026", "02/01/2026", "21/12/2025")
sort(as_text)
#   "02/01/2026" "10/01/2026" "21/12/2025"
#   ALPHABETICAL: the 2025 date sorts LAST. This is wrong and silent.

as_dates <- dmy(as_text)
sort(as_dates)
#   "2025-12-21" "2026-01-02" "2026-01-10"   <- correct chronological order

# Same lesson as Course 5: a date column stored as VARCHAR sorts and compares
# alphabetically, which is almost never what you want.

# --- FORMAT CODES (also used by format() in base R) ---
#   %Y 4-digit year   %y 2-digit year   %m month number
#   %B full month     %b abbreviated    %d day of month
#   %A full weekday   %a abbreviated    %H:%M:%S time

In Python, python/11_dates.py:

"""Experiment 11 (Python equivalent) -- working with dates.

R version: ../11_dates.R  (as.Date, format, lubridate)
"""
from datetime import date, datetime, timedelta

if __name__ == "__main__":
    # Step 1: Parse the dates
    print("PARSING                        R: as.Date('2026-08-26')")
    d = date.fromisoformat("2026-08-26")
    print(f"  ISO         '2026-08-26' -> {d}")
    d2 = datetime.strptime("26/08/2026", "%d/%m/%Y").date()
    print(f"  DD/MM/YYYY  '26/08/2026' -> {d2}   R: format='%d/%m/%Y'")
    print(f"  same date: {d == d2}")

    # Step 2: Take a date apart
    print("\nCOMPONENTS                     R: lubridate::year(), month(), day()")
    print(f"  year={d.year}  month={d.month}  day={d.day}")
    print(f"  weekday       = {d.strftime('%A')}   R: wday(d, label=TRUE)")
    print(f"  day of year   = {d.timetuple().tm_yday}")
    print(f"  ISO week      = {d.isocalendar().week}")

    # Step 3: Do arithmetic on dates
    print("\nARITHMETIC                     R: d + 30 ; difftime()")
    print(f"  d + 30 days   = {d + timedelta(days=30)}")
    print(f"  d - 7 days    = {d - timedelta(days=7)}")
    later = date.fromisoformat("2026-12-25")
    print(f"  days to {later} = {(later - d).days}")

    # Step 4: Format them
    print("\nFORMATTING                     R: format(d, '%d %B %Y')")
    for fmt, label in (("%d-%m-%Y", "DD-MM-YYYY"), ("%d %B %Y", "long"),
                       ("%b %d, %Y", "abbreviated"), ("%Y-%m-%d", "ISO")):
        print(f"  {label:<12} {d.strftime(fmt)}")

    # Step 5: Sort dates as text and as dates
    print("\nWHY DATES MUST NOT BE STRINGS")
    as_text = ["10/01/2026", "02/01/2026", "21/12/2025"]
    as_dates = [datetime.strptime(x, "%d/%m/%Y").date() for x in as_text]
    print(f"  sorted as text : {sorted(as_text)}")
    print(f"  sorted as dates: {[str(x) for x in sorted(as_dates)]}")
    print("  Text sorting puts 02/01 before 10/01 before 21/12 -- alphabetical,")
    print("  not chronological. The 2025 date ends up LAST. This is exactly the")
    print("  bug that makes date columns stored as VARCHAR dangerous (Course 5).")

    assert sorted(as_dates)[0].year == 2025, "chronological order must start in 2025"
    assert sorted(as_text)[0].startswith("02"), "text order must start with 02"
    print("\n  demonstrated: text order and date order genuinely differ ✓")

5. Execution and Results

In R, 11_dates.R:

OUTPUT

[1] "2026-10-04"
[1] "2026-10-04 12:00:00 UTC"
[1] "26-08-2026"
[1] "26 August 2026"
[1] "Wednesday"
[1] "August"
[1] "2026-09-25"
Time difference of 121 days

Attaching package: ‘lubridate’

The following objects are masked from ‘package:base’:

    date, intersect, setdiff, union

[1] "2026-08-26"
[1] "2026-08-26"
[1] "2026-08-26"
[1] 2026
[1] 8
[1] 26
[1] Wed
Levels: Sun < Mon < Tue < Wed < Thu < Fri < Sat
[1] "2026-09-25"
[1] "2026-09-26"
[1] "2027-08-26"
[1] "2026-09-26"
[1] "02/01/2026" "10/01/2026" "21/12/2025"
[1] "2025-12-21" "2026-01-02" "2026-01-10"

In Python, python/11_dates.py:

OUTPUT

PARSING                        R: as.Date('2026-08-26')
  ISO         '2026-08-26' -> 2026-08-26
  DD/MM/YYYY  '26/08/2026' -> 2026-08-26   R: format='%d/%m/%Y'
  same date: True

COMPONENTS                     R: lubridate::year(), month(), day()
  year=2026  month=8  day=26
  weekday       = Wednesday   R: wday(d, label=TRUE)
  day of year   = 238
  ISO week      = 35

ARITHMETIC                     R: d + 30 ; difftime()
  d + 30 days   = 2026-09-25
  d - 7 days    = 2026-08-19
  days to 2026-12-25 = 121

FORMATTING                     R: format(d, '%d %B %Y')
  DD-MM-YYYY   26-08-2026
  long         26 August 2026
  abbreviated  Aug 26, 2026
  ISO          2026-08-26

WHY DATES MUST NOT BE STRINGS
  sorted as text : ['02/01/2026', '10/01/2026', '21/12/2025']
  sorted as dates: ['2025-12-21', '2026-01-02', '2026-01-10']
  Text sorting puts 02/01 before 10/01 before 21/12 -- alphabetical,
  not chronological. The 2025 date ends up LAST. This is exactly the
  bug that makes date columns stored as VARCHAR dangerous (Course 5).

  demonstrated: text order and date order genuinely differ ✓

The first two lines are today's date and time: the output shown was made with the clock set to 4 October 2026, 12:00 UTC.

RESULT

26 August 2026 is a Wednesday, and 121 days before Christmas. Sorted as text, 21/12/2025 comes last; sorted as dates, first.

Experiment 12 — ggplot2

1. Question

Draw a scatter plot, a bar chart, a column chart, a histogram and boxplots of the students' data with ggplot2.

2. Aim

Build charts with ggplot2's grammar of layers.

3. Steps

  1. Make the students data frame.
  2. Draw a scatter plot with a fitted line.
  3. Draw a bar chart of counts.
  4. Draw a column chart of means.
  5. Draw a histogram.
  6. Draw boxplots, split by gender.
  7. Save a plot as PNG and PDF.

4. Programme

# =====================================================================
# Run with R 4.3.3 (Rscript --vanilla). What it prints, and the plots it
# draws, are on the lab page, and tools/data-science/run_r_equivalents.py
# runs it again. (Until October 2026 R could not be installed where these
# labs are checked, so this file was desk-checked only; every number in its
# comments has since been checked against R's own output.)
# =====================================================================
# Experiment 12: Visualise data with ggplot2
# No Python equivalent -- this demonstrates ggplot2's grammar specifically.

library(ggplot2)

# Step 1: Make the students data frame
students <- data.frame(
  name    = c("Ananya","Bhavana","Charan","Divya","Eshwar",
              "Fiona","Gopal","Harika","Ismail","Jyothi"),
  section = c("A","A","B","B","A","C","C","B","A","C"),
  gender  = c("F","F","M","F","M","F","M","F","M","F"),
  hours   = c(9, 5, 11, 4, 7, 8, 3, 10, 6, 2),
  marks   = c(85, 62, 91, 55, 74, 79, 48, 88, 68, 41)
)

# --- SCATTER: two numeric variables ---
# Step 2: Draw a scatter plot with a fitted line
ggplot(students, aes(x = hours, y = marks, colour = section)) +
  geom_point(size = 3, alpha = 0.8) +
  geom_smooth(method = "lm", se = TRUE, colour = "grey40") +
  labs(title = "Marks against study hours",
       x = "Hours studied per week", y = "Marks out of 100",
       colour = "Section") +
  theme_minimal()

# --- BAR: counts per category ---
# Step 3: Draw a bar chart of counts
ggplot(students, aes(x = section, fill = section)) +
  geom_bar() +                       # geom_bar COUNTS rows for you
  labs(title = "Students per section") +
  theme_minimal() + theme(legend.position = "none")

# --- COLUMN: a value you already have ---
# Step 4: Draw a column chart of means
avg <- aggregate(marks ~ section, data = students, FUN = mean)
ggplot(avg, aes(x = section, y = marks, fill = section)) +
  geom_col() +                       # geom_col uses YOUR value as the height
  labs(title = "Mean marks per section")

# geom_bar() vs geom_col() is the classic exam question:
#   geom_bar  default stat = "count"    -> it counts rows
#   geom_col  default stat = "identity" -> it uses your y value
# Reaching for geom_bar when you already have the value gives bars of height 1.

# --- HISTOGRAM: distribution of one numeric variable ---
# Step 5: Draw a histogram
ggplot(students, aes(x = marks)) +
  geom_histogram(bins = 6, fill = "#1e7fbf", colour = "white") +
  labs(title = "Distribution of marks")

# --- BOXPLOT: distribution by group, with outliers ---
# Step 6: Draw boxplots, split by gender
ggplot(students, aes(x = section, y = marks, fill = section)) +
  geom_boxplot(alpha = 0.7, outlier.colour = "red") +
  facet_wrap(~ gender) +             # small multiples
  labs(title = "Marks by section", subtitle = "Split by gender") +
  theme_minimal() + theme(legend.position = "none")

# NOTE for boxplots: fill = interior, colour = outline. Using colour where you
# meant fill gives an outlined but empty box.

# --- EXPORT ---
# Step 7: Save a plot as PNG and PDF
p <- ggplot(students, aes(hours, marks)) + geom_point()
ggsave("marks_plot.png", plot = p, width = 8, height = 5, dpi = 300)
ggsave("marks_plot.pdf", plot = p, width = 8, height = 5)   # vector, for print

# Always pass plot = explicitly. ggsave() otherwise saves the LAST plot
# displayed, which in a script is rarely the one you meant.

# LAYERS COMBINE WITH +, NOT %>%. Mixing them is the commonest ggplot2 error.

5. Execution and Results

OUTPUT

`geom_smooth()` using formula = 'y ~ x'

12_ggplot.R: chart 1 of 5, drawn by the program

12_ggplot.R: chart 2 of 5, drawn by the program

12_ggplot.R: chart 3 of 5, drawn by the program

12_ggplot.R: chart 4 of 5, drawn by the program

12_ggplot.R: chart 5 of 5, drawn by the program

The five plots are in the order the script draws them. The one line it prints is ggplot2's note that geom_smooth() fitted a straight line. The two saved files, marks_plot.png and marks_plot.pdf, are not shown.

RESULT

All five charts draw; geom_bar() counts the students in each section, and geom_col() plots the mean marks given to it.

Experiment 13 — K-Means clustering

1. Question

Segment 60 customers into three clusters by income and age.

2. Aim

Cluster with K-Means in R, choose k, and see why the data must be scaled first.

3. Steps

In R, 13_kmeans.R:

  1. Make the customer data, after setting the seed.
  2. Scale it, and cluster into three.
  3. Read the clusters.
  4. Profile the clusters, and plot them.
  5. Choose k by the elbow method.
  6. Cluster without scaling, and compare.

In Python, python/13_kmeans.py:

  1. Make the customer data, from a seeded generator.
  2. Cluster without scaling.
  3. Cluster with scaling.
  4. Choose k by the elbow method.
  5. Compare the two clusterings, and the variances.

SCALING IS THE WHOLE EXPERIMENT

The lab data has an income:age variance ratio of about 3.5 billion to 1. Without scale(), K-Means clusters on income alone and age contributes nothing measurable. Run it both ways and compare table(km$cluster, km_raw$cluster) — seeing the two solutions disagree is more convincing than being told they will.

4. Programme

In R, 13_kmeans.R:

# =====================================================================
# Run with R 4.3.3 (Rscript --vanilla). What it prints, and the plots it
# draws, are on the lab page, and tools/data-science/run_r_equivalents.py
# runs it again. (Until October 2026 R could not be installed where these
# labs are checked, so this file was desk-checked only; every number in its
# comments has since been checked against R's own output.)
# =====================================================================
# Experiment 13: K-Means clustering
# Python equivalent: python/13_kmeans.py

# Step 1: Make the customer data, after setting the seed
set.seed(42)     # ALWAYS, or your clusters differ on every run

customers <- data.frame(
  income = c(rnorm(20, 300000, 40000),
             rnorm(20, 900000, 60000),
             rnorm(20, 1500000, 80000)),
  age    = c(rnorm(20, 28, 4), rnorm(20, 45, 5), rnorm(20, 38, 6))
)

# --- SCALING IS MANDATORY ---
# income spans ~1,200,000; age spans ~40. Euclidean distance is therefore
# driven almost entirely by income, and age contributes nothing.
# The variance ratio here is about 3,500,000,000 : 1 (var(customers$income) /
# var(customers$age) is 3.48 billion). [Corrected: this said 4,000,000,000.]
# Step 2: Scale it, and cluster into three
scaled <- scale(customers)

km <- kmeans(scaled, centers = 3, nstart = 25)
# nstart = 25 runs the algorithm 25 times from different random starts and
# keeps the best. K-Means converges to a LOCAL optimum that depends on
# initialisation, so a single run can be poor.

# Step 3: Read the clusters
km$cluster            # cluster assignment per observation
km$centers            # centroids, in SCALED units
km$size               # observations per cluster
km$tot.withinss       # total within-cluster sum of squares
km$betweenss / km$totss   # proportion of variance explained

# Step 4: Profile the clusters, and plot them
customers$cluster <- factor(km$cluster)
aggregate(. ~ cluster, data = customers, FUN = mean)   # profile in REAL units

plot(customers$income, customers$age, col = km$cluster, pch = 19,
     xlab = "Annual income", ylab = "Age", main = "Customer segments")

# --- CHOOSING k: the elbow method ---
# Step 5: Choose k by the elbow method
wss <- sapply(1:10, function(k) kmeans(scaled, k, nstart = 10)$tot.withinss)
plot(1:10, wss, type = "b", pch = 19,
     xlab = "Number of clusters k", ylab = "Within-cluster sum of squares")
# WSS always falls as k rises -- at k = n it is zero. The "elbow" is where
# extra clusters stop buying much.

# --- A less subjective alternative: the silhouette ---
# library(cluster)
# sil <- silhouette(km$cluster, dist(scaled)); mean(sil[, 3])

# COMPARE: without scaling, the clustering is driven by income alone.
# Step 6: Cluster without scaling, and compare
km_raw <- kmeans(customers[, 1:2], centers = 3, nstart = 25)
table(km$cluster, km_raw$cluster)   # the two solutions differ

In Python, python/13_kmeans.py:

"""Experiment 13 (Python equivalent) -- K-Means clustering.

R version: ../13_kmeans.R  (kmeans(scale(df), centers = 3, nstart = 25))

Includes the scaling demonstration from Unit 4: the same data clustered with
and without scale(), to show that forgetting it changes the answer.
"""
import numpy as np
from sklearn.cluster import KMeans
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import silhouette_score

# Step 1: Make the customer data, from a seeded generator
RNG = np.random.default_rng(42)

# Customers: annual income (rupees) and age. Deliberately different scales.
income = np.concatenate([RNG.normal(3_00_000, 40_000, 20),
                         RNG.normal(9_00_000, 60_000, 20),
                         RNG.normal(15_00_000, 80_000, 20)])
age = np.concatenate([RNG.normal(28, 4, 20), RNG.normal(45, 5, 20),
                      RNG.normal(38, 6, 20)])
X = np.column_stack([income, age])


def fit(data, k=3):
    km = KMeans(n_clusters=k, n_init=25, random_state=42)
    labels = km.fit_predict(data)
    return km, labels


if __name__ == "__main__":
    print("K-MEANS                        R: kmeans(scale(df), 3, nstart = 25)")
    print(f"  {X.shape[0]} customers, 2 features")
    print(f"  income range {income.min():,.0f} to {income.max():,.0f}")
    print(f"  age    range {age.min():.0f} to {age.max():.0f}")

    # Step 2: Cluster without scaling
    print("\nWITHOUT scaling")
    km_raw, lab_raw = fit(X)
    print(f"  silhouette = {silhouette_score(X, lab_raw):.4f}")
    for c in range(3):
        m = X[lab_raw == c]
        print(f"    cluster {c}: n={len(m):2d}  mean income={m[:,0].mean():>10,.0f}"
              f"  mean age={m[:,1].mean():5.1f}")

    # Step 3: Cluster with scaling
    print("\nWITH scaling                   R: scale() before kmeans()")
    Xs = StandardScaler().fit_transform(X)
    km_s, lab_s = fit(Xs)
    print(f"  silhouette = {silhouette_score(Xs, lab_s):.4f}")
    for c in range(3):
        m = X[lab_s == c]
        print(f"    cluster {c}: n={len(m):2d}  mean income={m[:,0].mean():>10,.0f}"
              f"  mean age={m[:,1].mean():5.1f}")

    # Step 4: Choose k by the elbow method
    print("\nELBOW METHOD                   R: sapply(1:10, ...$tot.withinss)")
    for k in range(1, 8):
        km = KMeans(n_clusters=k, n_init=10, random_state=42).fit(Xs)
        bar = "#" * int(km.inertia_ / 6)
        print(f"    k={k}  WSS={km.inertia_:7.2f}  {bar}")

    # Step 5: Compare the two clusterings, and the variances
    agree = (lab_raw == lab_s).mean()
    best_agree = max(agree, 1 - agree)
    print(f"\n  Unscaled and scaled clusterings agree on {best_agree:.0%} of points"
          if best_agree < 1 else "\n  Both clusterings agree here")
    print("  With this data the income separation is so wide that both find it,")
    print("  but the unscaled version is driven by income ALONE -- age contributes")
    print("  essentially nothing, because a 20-year age gap is 20 units against an")
    print("  income gap of 600,000. Scaling is what lets age matter at all.")

    var_ratio = income.var() / age.var()
    print(f"\n  variance ratio income:age = {var_ratio:,.0f} : 1")
    assert var_ratio > 1000, "the scale problem must be real for the point to stand"
    print("  that ratio is why scale() is mandatory, not optional ✓")

5. Execution and Results

In R, 13_kmeans.R:

OUTPUT

 [1] 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3
[39] 3 3 2 2 2 3 2 2 2 2 2 2 2 2 2 2 2 2 2 3 2 2
       income         age
1 -1.18965363 -1.04002216
2  1.22234331 -0.09472455
3  0.08140423  1.02297659
[1] 20 18 22
[1] 17.83747
[1] 0.848835
  cluster    income      age
1       1  307676.8 28.64240
2       2 1502749.6 36.58546
3       3  937448.2 45.97717

     1  2  3
  1  0 20  0
  2 18  0  0
  3  2  0 20

13_kmeans.R: chart 1 of 2, drawn by the program

13_kmeans.R: chart 2 of 2, drawn by the program

In Python, python/13_kmeans.py:

OUTPUT

K-MEANS                        R: kmeans(scale(df), 3, nstart = 25)
  60 customers, 2 features
  income range 221,959 to 1,619,595
  age    range 21 to 48

WITHOUT scaling
  silhouette = 0.9086
    cluster 0: n=20  mean income=   906,568  mean age= 43.2
    cluster 1: n=20  mean income=   298,683  mean age= 27.6
    cluster 2: n=20  mean income= 1,509,546  mean age= 37.3

WITH scaling                   R: scale() before kmeans()
  silhouette = 0.6446
    cluster 0: n=19  mean income= 1,513,694  mean age= 36.8
    cluster 1: n=20  mean income=   298,683  mean age= 27.6
    cluster 2: n=21  mean income=   931,528  mean age= 43.4

ELBOW METHOD                   R: sapply(1:10, ...$tot.withinss)
    k=1  WSS= 120.00  ####################
    k=2  WSS=  37.48  ######
    k=3  WSS=  15.97  ##
    k=4  WSS=  11.17  #
    k=5  WSS=   8.00  #
    k=6  WSS=   5.82
    k=7  WSS=   3.99

  Unscaled and scaled clusterings agree on 65% of points
  With this data the income separation is so wide that both find it,
  but the unscaled version is driven by income ALONE -- age contributes
  essentially nothing, because a 20-year age gap is 20 units against an
  income gap of 600,000. Scaling is what lets age matter at all.

  variance ratio income:age = 4,363,697,116 : 1
  that ratio is why scale() is mandatory, not optional ✓

Here the two solutions disagree on 2 of the 60 customers: the income groups are far enough apart that income alone nearly finds them. Corrected: the ratio was given as 4 billion. R's data gives 3.48 billion; the Python equivalent's own random data gives 4.36 billion.

RESULT

The three clusters have 20, 18 and 22 customers, and explain 85% of the variance. They average about 3.1, 15.0 and 9.4 lakh rupees, at ages 29, 37 and 46.

Experiment 14 — Confusion matrix, accuracy, ROC

1. Question

Evaluate a classifier from its confusion matrix (TP 80, FP 20, FN 40, TN 860), and draw an ROC curve.

2. Aim

Compute and read a classifier's metrics with caret and pROC, and see the accuracy paradox.

3. Steps

In R, 14_evaluation.R:

  1. Make the actual and predicted labels.
  2. Build the confusion matrix and its metrics.
  3. Make scores for the ROC curve.
  4. Draw the ROC curve, and find the AUC.
  5. Find the best threshold.

In Python, python/14_evaluation.py:

  1. Make the labels from the Unit 4 counts.
  2. Build the confusion matrix and its metrics.
  3. Compare with the trivial baseline.
  4. Make scores, and find the AUC.
  5. Check against Unit 4 Problem 1.

4. Programme

In R, 14_evaluation.R:

# =====================================================================
# Run with R 4.3.3 (Rscript --vanilla). What it prints, and the plots it
# draws, are on the lab page, and tools/data-science/run_r_equivalents.py
# runs it again. (Until October 2026 R could not be installed where these
# labs are checked, so this file was desk-checked only; every number in its
# comments has since been checked against R's own output.)
# =====================================================================
# Experiment 14: Confusion matrix, accuracy and ROC
# Python equivalent: python/14_evaluation.py
# Same counts as Course 6 Unit 4 Problem 1: TP=80 FP=20 FN=40 TN=860

library(caret); library(pROC)

# Step 1: Make the actual and predicted labels
actual    <- factor(c(rep(1, 120), rep(0, 880)))
predicted <- factor(c(rep(1, 80), rep(0, 40), rep(1, 20), rep(0, 860)))

# Step 2: Build the confusion matrix and its metrics
cm <- confusionMatrix(predicted, actual, positive = "1")
cm
#   Accuracy    : 0.9400
#   Sensitivity : 0.6667   <- RECALL
#   Specificity : 0.9773
#   Pos Pred Val: 0.8000   <- PRECISION
#   F1          : 0.7273

cm$table         # the confusion matrix itself
cm$byClass       # every derived metric

# THE ACCURACY PARADOX:
# 88% of these patients are healthy, so "always predict healthy" already
# scores 0.88. This model's 0.94 beats that by only 0.06 -- and it MISSES
# 40 of the 120 real cases. Accuracy alone conceals that entirely.

# --- ROC and AUC: need SCORES, not hard labels ---
# probs <- predict(model, test, type = "prob")[, "1"]
# Step 3: Make scores for the ROC curve
set.seed(42)
probs <- ifelse(actual == 1, rbeta(1000, 5, 2), rbeta(1000, 2, 5))

# Step 4: Draw the ROC curve, and find the AUC
r <- roc(actual, probs)
auc(r)                       # ~0.96
plot(r, main = "ROC curve"); abline(a = 0, b = 1, lty = 2)

# AUC is the probability that the model ranks a random POSITIVE above a
# random NEGATIVE. 0.5 is the diagonal -- no better than guessing.
# Its advantage over accuracy is that it is THRESHOLD-INDEPENDENT.

# Step 5: Find the best threshold
coords(r, "best", ret = c("threshold", "sensitivity", "specificity"))

In Python, python/14_evaluation.py:

"""Experiment 14 (Python equivalent) -- confusion matrix, accuracy, ROC.

R version: ../14_evaluation.R  (caret::confusionMatrix, pROC::roc)
Reproduces the worked example from Course 6 Unit 4 practice problem 1, so the
notes and the code agree.
"""
import numpy as np
from sklearn.metrics import (confusion_matrix, accuracy_score, precision_score,
                             recall_score, f1_score, roc_auc_score, roc_curve)

# The exact counts from Unit 4 Problem 1: TP=80, FP=20, FN=40, TN=860
# Step 1: Make the labels from the Unit 4 counts
y_true = np.array([1] * 120 + [0] * 880)
y_pred = np.array([1] * 80 + [0] * 40 + [1] * 20 + [0] * 860)


def metrics(y_true, y_pred):
    tn, fp, fn, tp = confusion_matrix(y_true, y_pred).ravel()
    return dict(
        tp=tp, fp=fp, fn=fn, tn=tn,
        accuracy=(tp + tn) / (tp + tn + fp + fn),
        precision=tp / (tp + fp),
        recall=tp / (tp + fn),
        specificity=tn / (tn + fp),
        f1=2 * tp / (2 * tp + fp + fn),
    )


if __name__ == "__main__":
    # Step 2: Build the confusion matrix and its metrics
    m = metrics(y_true, y_pred)
    print("CONFUSION MATRIX               R: caret::confusionMatrix()")
    print(f"                  Predicted +   Predicted -")
    print(f"    Actual +      {m['tp']:>10}   {m['fn']:>11}")
    print(f"    Actual -      {m['fp']:>10}   {m['tn']:>11}")

    print("\nMETRICS")
    for k in ("accuracy", "precision", "recall", "specificity", "f1"):
        print(f"    {k:<12} {m[k]:.4f}")

    print(f"\n    sklearn cross-check:")
    print(f"      accuracy  {accuracy_score(y_true, y_pred):.4f}")
    print(f"      precision {precision_score(y_true, y_pred):.4f}")
    print(f"      recall    {recall_score(y_true, y_pred):.4f}")
    print(f"      f1        {f1_score(y_true, y_pred):.4f}")

    # Step 3: Compare with the trivial baseline
    baseline = (y_true == 0).mean()
    print(f"\n  THE ACCURACY PARADOX")
    print(f"    accuracy of this model            = {m['accuracy']:.4f}")
    print(f"    accuracy of 'always predict 0'    = {baseline:.4f}")
    print(f"    the model beats the trivial baseline by only "
          f"{m['accuracy'] - baseline:.4f}")
    print(f"    but recall is {m['recall']:.3f} -- it MISSES "
          f"{m['fn']} of {m['tp']+m['fn']} real cases")

    # Step 4: Make scores, and find the AUC
    # ROC needs scores, not hard labels.
    rng = np.random.default_rng(42)
    scores = np.where(y_true == 1,
                      rng.beta(5, 2, size=len(y_true)),
                      rng.beta(2, 5, size=len(y_true)))
    auc = roc_auc_score(y_true, scores)
    fpr, tpr, _ = roc_curve(y_true, scores)
    print(f"\nROC / AUC                      R: pROC::roc(); auc()")
    print(f"    AUC = {auc:.4f}")
    print("    (AUC is the probability the model ranks a random positive")
    print("     above a random negative -- 0.5 would be random guessing)")

    # Step 5: Check against Unit 4 Problem 1
    assert abs(m["accuracy"] - 0.940) < 1e-9
    assert abs(m["precision"] - 0.800) < 1e-9
    assert abs(m["recall"] - 2/3) < 1e-9
    assert abs(m["f1"] - 0.727) < 1e-3
    print("\n  matches Unit 4 Problem 1 exactly ✓")

5. Execution and Results

In R, 14_evaluation.R:

OUTPUT

Loading required package: ggplot2
Loading required package: lattice
Type 'citation("pROC")' for a citation.

Attaching package: ‘pROC’

The following objects are masked from ‘package:stats’:

    cov, smooth, var

Confusion Matrix and Statistics

          Reference
Prediction   0   1
         0 860  40
         1  20  80

               Accuracy : 0.94
                 95% CI : (0.9234, 0.9539)
    No Information Rate : 0.88
    P-Value [Acc > NIR] : 1.343e-10

                  Kappa : 0.6939

 Mcnemar's Test P-Value : 0.01417

            Sensitivity : 0.6667
            Specificity : 0.9773
         Pos Pred Value : 0.8000
         Neg Pred Value : 0.9556
             Prevalence : 0.1200
         Detection Rate : 0.0800
   Detection Prevalence : 0.1000
      Balanced Accuracy : 0.8220

       'Positive' Class : 1

          Reference
Prediction   0   1
         0 860  40
         1  20  80
         Sensitivity          Specificity       Pos Pred Value
           0.6666667            0.9772727            0.8000000
      Neg Pred Value            Precision               Recall
           0.9555556            0.8000000            0.6666667
                  F1           Prevalence       Detection Rate
           0.7272727            0.1200000            0.0800000
Detection Prevalence    Balanced Accuracy
           0.1000000            0.8219697
Setting levels: control = 0, case = 1
Setting direction: controls < cases
Area under the curve: 0.9633
  threshold sensitivity specificity
1  0.519149   0.9083333   0.9159091

14_evaluation.R: chart 1 of 1, drawn by the program

In Python, python/14_evaluation.py:

OUTPUT

CONFUSION MATRIX               R: caret::confusionMatrix()
                  Predicted +   Predicted -
    Actual +              80            40
    Actual -              20           860

METRICS
    accuracy     0.9400
    precision    0.8000
    recall       0.6667
    specificity  0.9773
    f1           0.7273

    sklearn cross-check:
      accuracy  0.9400
      precision 0.8000
      recall    0.6667
      f1        0.7273

  THE ACCURACY PARADOX
    accuracy of this model            = 0.9400
    accuracy of 'always predict 0'    = 0.8800
    the model beats the trivial baseline by only 0.0600
    but recall is 0.667 -- it MISSES 40 of 120 real cases

ROC / AUC                      R: pROC::roc(); auc()
    AUC = 0.9652
    (AUC is the probability the model ranks a random positive
     above a random negative -- 0.5 would be random guessing)

  matches Unit 4 Problem 1 exactly ✓

Accuracy, 0.94, beats always predicting "healthy", 0.88, by only 0.06, and the model misses 40 of the 120 real cases. The AUC is for scores simulated to separate the classes.

RESULT

Accuracy 0.94, sensitivity (recall) 0.6667, specificity 0.9773, precision 0.80 and F1 0.7273. The AUC is 0.9633, and the best threshold is 0.519.

Experiment 15 — Text mining and word cloud

1. Question

Clean five course reviews, count their terms, draw a word cloud, and weight the terms by TF-IDF.

2. Aim

Mine text in R with tm: clean it, build a term-document matrix, and weight it.

3. Steps

In R, 15_text_mining.R:

  1. Enter the reviews.
  2. Clean the text: case, punctuation, numbers, stop words, stems.
  3. Count the terms.
  4. Draw the word cloud and a bar chart.
  5. Weight the terms by TF-IDF.
  6. Find the frequent and the associated terms.

In Python, python/15_text_mining.py:

  1. Clean the text.
  2. Count the terms.
  3. Build the term-document matrix.
  4. Weight the terms by TF-IDF.
  5. Check that a term in every document weighs zero.

4. Programme

In R, 15_text_mining.R:

# =====================================================================
# Run with R 4.3.3 (Rscript --vanilla). What it prints, and the plots it
# draws, are on the lab page, and tools/data-science/run_r_equivalents.py
# runs it again. (Until October 2026 R could not be installed where these
# labs are checked, so this file was desk-checked only; every number in its
# comments has since been checked against R's own output.)
# =====================================================================
# Experiment 15: Text mining and word cloud
# Python equivalent: python/15_text_mining.py

library(tm); library(wordcloud); library(RColorBrewer)

# Step 1: Enter the reviews
reviews <- c(
  "The data science course is excellent and the teaching is excellent",
  "Excellent course with excellent practical data examples",
  "The practical sessions are useful but the course is fast",
  "Data analysis practical work is the best part of this course",
  "Teaching on this course is good and the data examples are practical")

# --- THE PREPROCESSING PIPELINE -- each step earns marks ---
# Step 2: Clean the text: case, punctuation, numbers, stop words, stems
corpus <- Corpus(VectorSource(reviews))
corpus <- tm_map(corpus, content_transformer(tolower))   # case-fold
corpus <- tm_map(corpus, removePunctuation)              # "data." -> "data"
corpus <- tm_map(corpus, removeNumbers)
corpus <- tm_map(corpus, removeWords, stopwords("english"))  # the, is, and...
corpus <- tm_map(corpus, stripWhitespace)
corpus <- tm_map(corpus, stemDocument)                   # running -> run

# --- TERM-DOCUMENT MATRIX ---
# Step 3: Count the terms
tdm <- TermDocumentMatrix(corpus)
m <- as.matrix(tdm)
freq <- sort(rowSums(m), decreasing = TRUE)
head(freq, 10)
inspect(tdm)

# --- WORD CLOUD ---
# Step 4: Draw the word cloud and a bar chart
set.seed(42)
wordcloud(names(freq), freq, min.freq = 1, max.words = 100,
          random.order = FALSE, colors = brewer.pal(8, "Dark2"))

barplot(head(freq, 8), las = 2, col = "#1e7fbf",
        main = "Most frequent terms")

# --- TF-IDF: weight by how DISTINCTIVE a term is ---
# Step 5: Weight the terms by TF-IDF
tdm_tfidf <- TermDocumentMatrix(corpus,
               control = list(weighting = weightTfIdf))
# TF-IDF(t,d) = TF(t,d) * log(N / DF(t))
#
# "course" appears in all 5 documents, so DF = N = 5, log(5/5) = 0, and its
# TF-IDF is EXACTLY ZERO. A term present everywhere distinguishes nothing.
# That is the whole point of the weighting, and it is why TF-IDF beats raw
# counts for finding what a document is actually ABOUT.

# Step 6: Find the frequent and the associated terms
findFreqTerms(tdm, lowfreq = 3)
findAssocs(tdm, "practic", 0.5)     # note the STEM, not "practical"

# STEMMING vs LEMMATISATION (a standard two-mark question):
#   stemming       chops suffixes mechanically; may give a non-word
#                  "studies" -> "studi"      fast
#   lemmatisation  uses vocabulary and grammar; returns a real word
#                  "studies" -> "study"      slower, more accurate

In Python, python/15_text_mining.py:

"""Experiment 15 (Python equivalent) -- text mining and word frequency.

R version: ../15_text_mining.R  (tm + wordcloud)
No word cloud is drawn here -- the frequencies that WOULD drive one are what
matter, and those are verifiable. The R script draws the cloud.
"""
import math, re
from collections import Counter

STOPWORDS = {
    "the", "is", "at", "which", "on", "a", "an", "and", "or", "but", "in",
    "with", "to", "for", "of", "as", "by", "that", "this", "it", "from",
    "be", "are", "was", "were", "has", "have", "had", "i", "you", "we",
}

REVIEWS = [
    "The data science course is excellent and the teaching is excellent",
    "Excellent course with excellent practical data examples",
    "The practical sessions are useful but the course is fast",
    "Data analysis practical work is the best part of this course",
    "Teaching on this course is good and the data examples are practical",
]
# Note: "course" and "data" deliberately appear in EVERY review, so the TF-IDF
# demonstration below has something real to weight to zero.


def preprocess(text):
    text = text.lower()
    text = re.sub(r"[^a-z\s]", " ", text)          # remove punctuation, digits
    tokens = text.split()
    return [t for t in tokens if t not in STOPWORDS and len(t) > 2]


def stem(word):
    """A crude Porter-style suffix stripper, enough to show the idea."""
    for suffix in ("ing", "edly", "ed", "es", "s"):
        if word.endswith(suffix) and len(word) - len(suffix) >= 3:
            return word[: -len(suffix)]
    return word


def term_document_matrix(docs):
    tokenised = [preprocess(d) for d in docs]
    vocab = sorted({t for doc in tokenised for t in doc})
    return vocab, [[doc.count(t) for doc in tokenised] for t in vocab]


def tfidf(vocab, tdm, n_docs):
    out = {}
    for term, row in zip(vocab, tdm):
        tf = sum(row)
        df = sum(1 for c in row if c > 0)
        out[term] = tf * math.log(n_docs / df)
    return out


if __name__ == "__main__":
    # Step 1: Clean the text
    print("PREPROCESSING                  R: tm_map(corpus, ...)")
    print(f"  original : {REVIEWS[0]}")
    print(f"  cleaned  : {' '.join(preprocess(REVIEWS[0]))}")
    print(f"  stemmed  : {' '.join(stem(t) for t in preprocess(REVIEWS[0]))}")

    # Step 2: Count the terms
    all_tokens = [t for d in REVIEWS for t in preprocess(d)]
    freq = Counter(all_tokens)
    print(f"\nWORD FREQUENCY                 R: sort(rowSums(as.matrix(dtm)))")
    print(f"  {len(all_tokens)} tokens after stop-word removal, "
          f"{len(freq)} unique")
    for word, n in freq.most_common(8):
        print(f"    {word:<12} {n}  {'#' * n * 3}")

    vocab, tdm = term_document_matrix(REVIEWS)
    # Step 3: Build the term-document matrix
    print(f"\nTERM-DOCUMENT MATRIX           {len(vocab)} terms x {len(REVIEWS)} docs")
    print(f"    {'term':<12}" + "".join(f"D{i+1:<3}" for i in range(len(REVIEWS))))
    for term, row in list(zip(vocab, tdm))[:6]:
        print(f"    {term:<12}" + "".join(f"{c:<4}" for c in row))

    # Step 4: Weight the terms by TF-IDF
    scores = tfidf(vocab, tdm, len(REVIEWS))
    print("\nTF-IDF                         TF x log(N / DF)")
    for term, sc in sorted(scores.items(), key=lambda kv: -kv[1])[:6]:
        df = sum(1 for c in tdm[vocab.index(term)] if c > 0)
        print(f"    {term:<12} tf={sum(tdm[vocab.index(term)]):<3} "
              f"df={df:<3} tfidf={sc:.4f}")

    # Step 5: Check that a term in every document weighs zero
    in_all = [t for t in vocab if all(c > 0 for c in tdm[vocab.index(t)])]
    assert in_all, "the demonstration needs at least one ubiquitous term"
    print(f"\n  terms appearing in EVERY document: {in_all}")
    for t in in_all:
        print(f"    '{t}' -> tf-idf = {scores[t]:.4f}  (log(5/5) = 0)")
    print("  A term in every document carries no discriminating information,")
    print("  so TF-IDF weights it to exactly zero. That is the whole point.")

    for t in in_all:
        assert abs(scores[t]) < 1e-12, f"{t} should have zero tf-idf"
    print("\n  ubiquitous terms correctly weighted to zero ✓")

5. Execution and Results

In R, 15_text_mining.R:

OUTPUT

Loading required package: NLP
Loading required package: RColorBrewer
Warning message:
In tm_map.SimpleCorpus(corpus, content_transformer(tolower)) :
  transformation drops documents
Warning message:
In tm_map.SimpleCorpus(corpus, removePunctuation) :
  transformation drops documents
Warning message:
In tm_map.SimpleCorpus(corpus, removeNumbers) :
  transformation drops documents
Warning message:
In tm_map.SimpleCorpus(corpus, removeWords, stopwords("english")) :
  transformation drops documents
Warning message:
In tm_map.SimpleCorpus(corpus, stripWhitespace) :
  transformation drops documents
Warning message:
In tm_map.SimpleCorpus(corpus, stemDocument) :
  transformation drops documents
  cours    data   excel practic   teach  exampl  scienc    fast session     use
      5       4       4       4       2       2       1       1       1       1
<<TermDocumentMatrix (terms: 15, documents: 5)>>
Non-/sparse entries: 28/47
Sparsity           : 63%
Maximal term length: 7
Weighting          : term frequency (tf)
Sample             :
         Docs
Terms     1 2 3 4 5
  cours   1 1 1 1 1
  data    1 1 0 1 1
  exampl  0 1 0 0 1
  excel   2 2 0 0 0
  fast    0 0 1 0 0
  practic 0 1 1 1 1
  scienc  1 0 0 0 0
  session 0 0 1 0 0
  teach   1 0 0 0 1
  use     0 0 1 0 0
Warning message:
In TermDocumentMatrix.SimpleCorpus(corpus, control = list(weighting = weightTfIdf)) :
  custom functions are ignored
[1] "cours"   "data"    "excel"   "practic"
$practic
numeric(0)

15_text_mining.R: chart 1 of 2, drawn by the program

15_text_mining.R: chart 2 of 2, drawn by the program

In Python, python/15_text_mining.py:

OUTPUT

PREPROCESSING                  R: tm_map(corpus, ...)
  original : The data science course is excellent and the teaching is excellent
  cleaned  : data science course excellent teaching excellent
  stemmed  : data science course excellent teach excellent

WORD FREQUENCY                 R: sort(rowSums(as.matrix(dtm)))
  30 tokens after stop-word removal, 15 unique
    course       5  ###############
    data         4  ############
    excellent    4  ############
    practical    4  ############
    teaching     2  ######
    examples     2  ######
    science      1  ###
    sessions     1  ###

TERM-DOCUMENT MATRIX           15 terms x 5 docs
    term        D1  D2  D3  D4  D5
    analysis    0   0   0   1   0
    best        0   0   0   1   0
    course      1   1   1   1   1
    data        1   1   0   1   1
    examples    0   1   0   0   1
    excellent   2   2   0   0   0

TF-IDF                         TF x log(N / DF)
    excellent    tf=4   df=2   tfidf=3.6652
    examples     tf=2   df=2   tfidf=1.8326
    teaching     tf=2   df=2   tfidf=1.8326
    analysis     tf=1   df=1   tfidf=1.6094
    best         tf=1   df=1   tfidf=1.6094
    fast         tf=1   df=1   tfidf=1.6094

  terms appearing in EVERY document: ['course']
    'course' -> tf-idf = 0.0000  (log(5/5) = 0)
  A term in every document carries no discriminating information,
  so TF-IDF weights it to exactly zero. That is the whole point.

  ubiquitous terms correctly weighted to zero ✓

The "transformation drops documents" warnings are tm's, printed each time tm_map() runs on this kind of corpus; nothing is dropped, and the matrix still has all 5 documents. Stemming turns "excellent" into "excel", which is why "excel" is a frequent term.

RESULT

The most frequent stems are cours (5), data, excel and practic (4 each). "cours" is in every review, so its TF-IDF weight is zero.

Experiment 16 — ARIMA forecasting

1. Question

Model the monthly airline passenger series, 1949–1960, and forecast it two years ahead.

2. Aim

Decompose, difference, identify, fit, check and forecast a seasonal series with ARIMA in R.

3. Steps

In R, 16_arima.R:

  1. Load and plot the series, and take logs.
  2. Decompose it.
  3. Test for stationarity.
  4. Difference it.
  5. Read the ACF and PACF.
  6. Fit the model.
  7. Check the residuals.
  8. Forecast two years ahead.

In Python, python/16_arima.py:

  1. Make and show the series.
  2. Find the trend by a moving average.
  3. Difference it.
  4. Read the ACF before and after differencing.
  5. Read the PACF.
  6. Check what the series must show.

DIFFERENCE BEFORE YOU READ THE ACF

On the raw airline series the ACF decays slowly — the trend dominates — and the seasonality shows only as a ripple on that decay: it falls to 0.66 at lag 8 and rises again to 0.76 at lag 12. After one difference the oscillation stands out, peaking at lag 12 (0.83). Reading ACF/PACF on a trending series tells you little except "there is a trend".

4. Programme

In R, 16_arima.R:

# =====================================================================
# Run with R 4.3.3 (Rscript --vanilla). What it prints, and the plots it
# draws, are on the lab page, and tools/data-science/run_r_equivalents.py
# runs it again. (Until October 2026 R could not be installed where these
# labs are checked, so this file was desk-checked only; every number in its
# comments has since been checked against R's own output.)
# =====================================================================
# Experiment 16: Time series forecasting with ARIMA
# Python equivalent: python/16_arima.py (implements decomposition, differencing,
# ACF and PACF from first principles, on a series of its own)

library(forecast); library(tseries)

# Step 1: Load and plot the series, and take logs
data(AirPassengers)          # the classic monthly series, 1949-1960
ap <- AirPassengers

# --- 1. LOOK AT IT FIRST ---
plot(ap, main = "Monthly airline passengers")
# The seasonal swing GROWS with the level -> MULTIPLICATIVE, so take logs.
lap <- log(ap)
plot(lap)                    # now the swing is roughly constant -> additive

# --- 2. DECOMPOSE ---
# Step 2: Decompose it
decomp <- decompose(ap, type = "multiplicative")
plot(decomp)                 # observed / trend / seasonal / random
stl(lap, s.window = "periodic")   # more robust; works on the log series

# --- 3. TEST FOR STATIONARITY ---
# Step 3: Test for stationarity
adf.test(ap)
#   ADF:  H0 = NON-stationary (unit root)
#   large p  -> FAIL to reject -> the series is NOT stationary
#   Here, though, p is below 0.01: adf.test() allows for a linear trend, and
#   around that trend this series is stationary. [Corrected: this said the
#   raw series gives a large p.]
kpss.test(ap)
#   KPSS: H0 = STATIONARY  -- the OPPOSITE null.
#   Reading one test's p-value as though it were the other's gives exactly
#   the wrong conclusion. Write the null down before interpreting.
#   Here KPSS rejects (p below 0.01): the level is not constant. With ADF,
#   that makes the series trend-stationary; differencing deals with both.

ndiffs(lap)      # how many ordinary differences are needed
nsdiffs(lap)     # how many SEASONAL differences

# --- 4. DIFFERENCE ---
# Step 4: Difference it
d1 <- diff(lap)              # removes the trend
d12 <- diff(d1, lag = 12)    # removes the 12-month seasonality
adf.test(d12)                # now small p -> stationary

# --- 5. IDENTIFY THE ORDERS ---
# Step 5: Read the ACF and PACF
acf(ap,  main = "ACF of the RAW series")
# Slow decay -- the trend swamps everything -- with the seasonality only a
# ripple on it: the ACF falls to 0.66 at lag 8 and rises again to 0.76 at
# lag 12. This is why you difference first. [Corrected: this said the decay
# is monotonic and the seasonality invisible. That is true of the Python
# version's series, not of this one.]

acf(d12,  main = "ACF after differencing")     # q, from where it CUTS OFF
pacf(d12, main = "PACF after differencing")    # p, from where it CUTS OFF
#   ACF tails off, PACF cuts off after lag p  -> AR(p)
#   ACF cuts off after lag q, PACF tails off  -> MA(q)
#   Mnemonic: PACF gives p, ACF gives q.

# --- 6. FIT ---
# Step 6: Fit the model
fit <- auto.arima(lap)       # searches (p,d,q)(P,D,Q)[12] by AIC
summary(fit)

# --- 7. CHECK THE RESIDUALS -- the step students skip ---
# Step 7: Check the residuals
checkresiduals(fit)
# If the model is adequate its residuals are WHITE NOISE: no autocorrelation
# left, roughly normal, constant variance. Structure remaining in the
# residuals is signal the model failed to capture.
#
# Ljung-Box: H0 = residuals are independent.
# Here you WANT a LARGE p-value -- the opposite of most tests you have met.

# --- 8. FORECAST ---
# Step 8: Forecast two years ahead
fc <- forecast(fit, h = 24)
plot(fc)
exp(fc$mean)                 # back-transform from the log scale
accuracy(fit)                # ME, RMSE, MAE, MAPE

# MAPE is unit-free and therefore easy to compare across series, but it
# breaks down when actual values are near zero.

In Python, python/16_arima.py:

"""Experiment 16 (Python equivalent) -- time series and ARIMA fundamentals.

R version: ../16_arima.R  (ts, decompose, adf.test, auto.arima, forecast)

statsmodels is not installed, so rather than call a black-box ARIMA this file
implements the pieces the syllabus actually examines -- decomposition,
differencing, stationarity, ACF and PACF -- from first principles. That is more
useful anyway: these are the calculations an exam asks you to perform by hand.
"""
import math

# 36 months of sales: level 100, upward trend, 12-month seasonality, noise.
# Built deterministically so the numbers are reproducible.
def make_series(n=36):
    out = []
    for t in range(n):
        trend = 100 + 2.0 * t
        season = 15 * math.sin(2 * math.pi * t / 12)
        noise = 3 * math.sin(t * 7.13)          # deterministic pseudo-noise
        out.append(round(trend + season + noise, 2))
    return out


SERIES = make_series()


def moving_average(v, window):
    """Centred moving average -- the trend estimate in classical decomposition."""
    half = window // 2
    out = [None] * len(v)
    for i in range(half, len(v) - half):
        if window % 2 == 0:      # even window needs the 2xM smoothing
            block = v[i - half:i + half + 1]
            out[i] = (sum(block[1:-1]) + (block[0] + block[-1]) / 2) / window
        else:
            out[i] = sum(v[i - half:i + half + 1]) / window
    return out


def difference(v, lag=1):
    return [v[i] - v[i - lag] for i in range(lag, len(v))]


def acf(v, max_lag):
    n = len(v)
    m = sum(v) / n
    denom = sum((x - m) ** 2 for x in v)
    out = []
    for k in range(1, max_lag + 1):
        num = sum((v[i] - m) * (v[i - k] - m) for i in range(k, n))
        out.append(num / denom)
    return out


def pacf(v, max_lag):
    """PACF by the Durbin-Levinson recursion."""
    r = [1.0] + acf(v, max_lag)
    phi = [[0.0] * (max_lag + 1) for _ in range(max_lag + 1)]
    out = []
    for k in range(1, max_lag + 1):
        if k == 1:
            phi[1][1] = r[1]
        else:
            num = r[k] - sum(phi[k - 1][j] * r[k - j] for j in range(1, k))
            den = 1 - sum(phi[k - 1][j] * r[j] for j in range(1, k))
            phi[k][k] = num / den if den != 0 else 0.0
            for j in range(1, k):
                phi[k][j] = phi[k - 1][j] - phi[k][k] * phi[k - 1][k - j]
        out.append(phi[k][k])
    return out


def spark(values, width=40):
    lo, hi = min(values), max(values)
    span = hi - lo or 1
    return "".join("▁▂▃▄▅▆▇█"[min(7, int((v - lo) / span * 7))] for v in values)


if __name__ == "__main__":
    # Step 1: Make and show the series
    print("THE SERIES                     R: ts(sales, frequency = 12)")
    print(f"  {len(SERIES)} monthly observations")
    print(f"  {spark(SERIES)}")
    print(f"  first 6: {SERIES[:6]}")

    # Step 2: Find the trend by a moving average
    print("\nDECOMPOSITION                  R: decompose(ts)")
    trend = moving_average(SERIES, 12)
    known = [(i, t) for i, t in enumerate(trend) if t is not None]
    print(f"  trend (12-month centred MA), first and last known values:")
    print(f"    t={known[0][0]:2d} -> {known[0][1]:7.2f}")
    print(f"    t={known[-1][0]:2d} -> {known[-1][1]:7.2f}")
    slope = (known[-1][1] - known[0][1]) / (known[-1][0] - known[0][0])
    print(f"  implied slope = {slope:.3f} per month  (series was built with 2.0)")

    # Step 3: Difference it
    print("\nSTATIONARITY BY DIFFERENCING   R: diff(ts) ; ndiffs(ts)")
    d1 = difference(SERIES)
    for name, v in (("original", SERIES), ("differenced", d1)):
        m = sum(v) / len(v)
        sd = (sum((x - m) ** 2 for x in v) / (len(v) - 1)) ** 0.5
        first, second = v[:len(v)//2], v[len(v)//2:]
        drift = abs(sum(second)/len(second) - sum(first)/len(first))
        print(f"  {name:<12} mean={m:8.2f}  sd={sd:6.2f}  "
              f"first-half vs second-half mean gap = {drift:7.2f}")
    print("  A large gap between the halves' means IS non-stationarity in the")
    print("  mean. Differencing collapses it, which is what d=1 achieves.")

    # Step 4: Read the ACF before and after differencing
    print("\nACF -- RAW SERIES              R: acf(ts)")
    a = acf(SERIES, 14)
    for k, v in enumerate(a, 1):
        print(f"    lag {k:2d}  {v:+.4f}  {'+' * int(abs(v) * 30)}")
    print("  Slow, monotonic decay and nothing else. This is the classic")
    print("  signature of a TREND, and it is so dominant that the seasonality")
    print("  built into this series is completely invisible here.")

    print("\nACF -- DIFFERENCED SERIES      R: acf(diff(ts))")
    ad = acf(d1, 14)
    for k, v in enumerate(ad, 1):
        bar = "+" * int(abs(v) * 30)
        marker = ""
        if k == 12:
            marker = "   <- local MAXIMUM: period-12 seasonality"
        elif k == 6:
            marker = "   <- MINIMUM: half a period out of phase"
        print(f"    lag {k:2d}  {v:+.4f}  {bar}{marker}")

    print("\n  💡 THE LESSON: seasonality was NOT visible in the raw ACF, because")
    print("  the trend swamped it. Only after differencing does the oscillation")
    print("  appear -- negative around lag 6, peaking again at lag 12. This is")
    print("  why the order of operations matters: difference FIRST, then read")
    print("  the ACF and PACF. Reading them on a trending series tells you")
    print("  almost nothing except 'there is a trend'.")

    # Step 5: Read the PACF
    print("\nPACF                           R: pacf(ts)")
    for k, v in enumerate(pacf(SERIES, 8), 1):
        print(f"    lag {k:2d}  {v:+.4f}")

    print("\n  READING THEM (Unit 5 A.4):")
    print("    ACF tails off, PACF cuts off after lag p  -> AR(p), p from PACF")
    print("    ACF cuts off after lag q, PACF tails off  -> MA(q), q from ACF")
    print("    ACF decaying slowly                       -> difference first")

    # In the DIFFERENCED series lag 12 is a local maximum and lag 6 the
    # minimum -- the fingerprint of period-12 seasonality.
    # Step 6: Check what the series must show
    assert ad[11] > ad[10] and ad[11] > ad[12], "lag 12 must be a local maximum"
    assert ad[5] == min(ad), "lag 6 must be the minimum"
    # In the RAW series it decays monotonically over the first 12 lags.
    assert all(a[i] > a[i + 1] for i in range(11)), "raw ACF must decay monotonically"
    assert abs(slope - 2.0) < 0.4, f"recovered slope {slope} should be near 2.0"
    print("\n  raw ACF decays monotonically (trend); differenced ACF peaks at")
    print("  lag 12 and troughs at lag 6 (seasonality); slope recovered ✓")

5. Execution and Results

In R, 16_arima.R:

OUTPUT

Registered S3 method overwritten by 'quantmod':
  method            from
  as.zoo.data.frame zoo
 Call:
 stl(x = lap, s.window = "periodic")

Components
            seasonal    trend     remainder
Jan 1949 -0.09164042 4.829389 -0.0192493585
Feb 1949 -0.11402828 4.830368  0.0543447685
Mar 1949  0.01586585 4.831348  0.0355884457
Apr 1949 -0.01402759 4.833377  0.0404632511
May 1949 -0.01502478 4.835406 -0.0245905300
Jun 1949  0.10978976 4.838166 -0.0426814256
Jul 1949  0.21640041 4.840927 -0.0601151688
Aug 1949  0.20960587 4.843469 -0.0558624690
Sep 1949  0.06747156 4.846011 -0.0008273977
Oct 1949 -0.07024836 4.850883 -0.0015112948
Nov 1949 -0.21352774 4.855756  0.0021630667
Dec 1949 -0.10063625 4.864586  0.0067346600
Jan 1950 -0.09164042 4.873417 -0.0368443057
Feb 1950 -0.11402828 4.883282  0.0670284530
Mar 1950  0.01586585 4.893147  0.0397474219
Apr 1950 -0.01402759 4.903156  0.0161459950
May 1950 -0.01502478 4.913166 -0.0698276070
Jun 1950  0.10978976 4.925404 -0.0312477713
Jul 1950  0.21640041 4.937643 -0.0182444833
Aug 1950  0.20960587 4.954420 -0.0282273969
Sep 1950  0.06747156 4.971197  0.0239260449
Oct 1950 -0.07024836 4.991662 -0.0310647612
Nov 1950 -0.21352774 5.012127 -0.0624008823
Dec 1950 -0.10063625 5.031094  0.0111846224
Jan 1951 -0.09164042 5.050061  0.0183131352
Feb 1951 -0.11402828 5.065044  0.0596196836
Mar 1951  0.01586585 5.080027  0.0858909418
Apr 1951 -0.01402759 5.093932  0.0138460280
May 1951 -0.01502478 5.107837  0.0546824937
Jun 1951  0.10978976 5.121064 -0.0490698826
Jul 1951  0.21640041 5.134291 -0.0573861680
Aug 1951  0.20960587 5.146131 -0.0624323009
Sep 1951  0.06747156 5.157972 -0.0105077415
Oct 1951 -0.07024836 5.169854 -0.0120089320
Nov 1951 -0.21352774 5.181735  0.0153990463
Dec 1951 -0.10063625 5.194803  0.0178207135
Jan 1952 -0.09164042 5.207871  0.0254326447
Feb 1952 -0.11402828 5.220493  0.0864920261
Mar 1952  0.01586585 5.233115  0.0137094563
Apr 1952 -0.01402759 5.244400 -0.0318757882
May 1952 -0.01502478 5.255686 -0.0311749995
Jun 1952  0.10978976 5.266916  0.0077889001
Jul 1952  0.21640041 5.278147 -0.0564679741
Aug 1952  0.20960587 5.291899 -0.0125671875
Sep 1952  0.06747156 5.305651 -0.0307885329
Oct 1952 -0.07024836 5.323035 -0.0005129219
Nov 1952 -0.21352774 5.340418  0.0206040218
Dec 1952 -0.10063625 5.355790  0.0127045234
Jan 1953 -0.09164042 5.371162 -0.0014064946
Feb 1953 -0.11402828 5.381701  0.0104415885
Mar 1953  0.01586585 5.392241  0.0557248228
Apr 1953 -0.01402759 5.398479  0.0751345805
May 1953 -0.01502478 5.404716  0.0440308724
Jun 1953  0.10978976 5.406792 -0.0235205660
Jul 1953  0.21640041 5.408869 -0.0493198945
Aug 1953  0.20960587 5.407808 -0.0116118099
Sep 1953  0.06747156 5.406747 -0.0061588541
Oct 1953 -0.07024836 5.407020  0.0150868577
Nov 1953 -0.21352774 5.407292 -0.0008072455
Dec 1953 -0.10063625 5.412628 -0.0086865700
Jan 1954 -0.09164042 5.417964 -0.0082032035
Feb 1954 -0.11402828 5.425823 -0.0753528534
Mar 1954  0.01586585 5.433683  0.0100370842
Apr 1954 -0.01402759 5.442676 -0.0036988730
May 1954 -0.01502478 5.451670  0.0186755182
Jun 1954  0.10978976 5.463574  0.0025856037
Jul 1954  0.21640041 5.475477  0.0185495056
Aug 1954  0.20960587 5.488954 -0.0183873611
Sep 1954  0.06747156 5.502431 -0.0130746073
Oct 1954 -0.07024836 5.515678 -0.0117077856
Nov 1954 -0.21352774 5.528925 -0.0021914704
Dec 1954 -0.10063625 5.543178 -0.0088199260
Jan 1955 -0.09164042 5.557431  0.0231469788
Feb 1955 -0.11402828 5.572621 -0.0075538774
Mar 1955  0.01586585 5.587810 -0.0164272509
Apr 1955 -0.01402759 5.602576  0.0061625730
May 1955 -0.01502478 5.617343 -0.0038959909
Jun 1955  0.10978976 5.631685  0.0110981305
Jul 1955  0.21640041 5.646027  0.0347266908
Aug 1955  0.20960587 5.660026 -0.0203074185
Sep 1955  0.06747156 5.674026  0.0015057273
Oct 1955 -0.07024836 5.687411 -0.0040342159
Nov 1955 -0.21352774 5.700795 -0.0192075831
Dec 1955 -0.10063625 5.713683  0.0145740752
Jan 1956 -0.09164042 5.726571  0.0140435478
Feb 1956 -0.11402828 5.738688 -0.0006422520
Mar 1956  0.01586585 5.750805 -0.0077690470
Apr 1956 -0.01402759 5.761271 -0.0010403672
May 1956 -0.01502478 5.771737  0.0053388421
Jun 1956  0.10978976 5.780581  0.0338845974
Jul 1956  0.21640041 5.789426  0.0176216235
Aug 1956  0.20960587 5.797448 -0.0031667752
Sep 1956  0.06747156 5.805470 -0.0008241663
Oct 1956 -0.07024836 5.813734 -0.0199006689
Nov 1956 -0.21352774 5.821998 -0.0063513051
Dec 1956 -0.10063625 5.831654 -0.0074325201
Jan 1957 -0.09164042 5.841310  0.0029031831
Feb 1957 -0.11402828 5.852625 -0.0314869189
Mar 1957  0.01586585 5.863941 -0.0048761755
Apr 1957 -0.01402759 5.875005 -0.0087747176
May 1957 -0.01502478 5.886069  0.0010740550
Jun 1957  0.10978976 5.894711  0.0405045167
Jul 1957  0.21640041 5.903354  0.0222834352
Aug 1957  0.20960587 5.907786  0.0289376116
Sep 1957  0.06747156 5.912218  0.0217253156
Oct 1957 -0.07024836 5.912909  0.0066639927
Nov 1957 -0.21352774 5.913600  0.0202392244
Dec 1957 -0.10063625 5.914570  0.0031777723
Jan 1958 -0.09164042 5.915539  0.0050470568
Feb 1958 -0.11402828 5.918512 -0.0424324225
Mar 1958  0.01586585 5.921485 -0.0457068326
Apr 1958 -0.01402759 5.924977 -0.0587474166
May 1958 -0.01502478 5.928470 -0.0190421600
Jun 1958  0.10978976 5.932704  0.0328523706
Jul 1958  0.21640041 5.936938  0.0431056910
Aug 1958  0.20960587 5.943328  0.0716243517
Sep 1958  0.06747156 5.949718 -0.0157750808
Oct 1958 -0.07024836 5.957409 -0.0038387222
Nov 1958 -0.21352774 5.965101 -0.0150005051
Dec 1958 -0.10063625 5.973333 -0.0526139823
Jan 1959 -0.09164042 5.981566 -0.0038213288
Feb 1959 -0.11402828 5.992040 -0.0432006658
Mar 1959  0.01586585 6.002514 -0.0120262807
Apr 1959 -0.01402759 6.015640 -0.0201986959
May 1959 -0.01502478 6.028767  0.0265120912
Jun 1959  0.10978976 6.042198  0.0049914005
Jul 1959  0.21640041 6.055628  0.0342466268
Aug 1959  0.20960587 6.065881  0.0506625860
Sep 1959  0.06747156 6.076134 -0.0058783006
Oct 1959 -0.07024836 6.084145 -0.0050832771
Nov 1959 -0.21352774 6.092156  0.0130161013
Dec 1959 -0.10063625 6.100500  0.0040231864
Jan 1960 -0.09164042 6.108844  0.0158822334
Feb 1960 -0.11402828 6.117934 -0.0351985510
Mar 1960  0.01586585 6.127024 -0.1050193083
Apr 1960 -0.01402759 6.135814  0.0116112609
May 1960 -0.01502478 6.144604  0.0273994037
Jun 1960  0.10978976 6.152986  0.0194908174
Jul 1960  0.21640041 6.161368  0.0551717057
Aug 1960  0.20960587 6.170124  0.0271500533
Sep 1960  0.06747156 6.178880 -0.0158702714
Oct 1960 -0.07024836 6.187594  0.0160525773
Nov 1960 -0.21352774 6.196307 -0.0166330133
Dec 1960 -0.10063625 6.204752 -0.0356905175

    Augmented Dickey-Fuller Test

data:  ap
Dickey-Fuller = -7.3186, Lag order = 5, p-value = 0.01
alternative hypothesis: stationary

Warning message:
In adf.test(ap) : p-value smaller than printed p-value

    KPSS Test for Level Stationarity

data:  ap
KPSS Level = 2.7395, Truncation lag parameter = 4, p-value = 0.01

Warning message:
In kpss.test(ap) : p-value smaller than printed p-value
[1] 1
[1] 1

    Augmented Dickey-Fuller Test

data:  d12
Dickey-Fuller = -5.1993, Lag order = 5, p-value = 0.01
alternative hypothesis: stationary

Warning message:
In adf.test(d12) : p-value smaller than printed p-value
Series: lap
ARIMA(0,1,1)(0,1,1)[12]

Coefficients:
          ma1     sma1
      -0.4018  -0.5569
s.e.   0.0896   0.0731

sigma^2 = 0.001371:  log likelihood = 244.7
AIC=-483.4   AICc=-483.21   BIC=-474.77

Training set error measures:
                       ME       RMSE        MAE        MPE      MAPE      MASE
Training set 0.0005730622 0.03504883 0.02626034 0.01098898 0.4752815 0.2169522
                   ACF1
Training set 0.01443892

    Ljung-Box test

data:  Residuals from ARIMA(0,1,1)(0,1,1)[12]
Q* = 26.446, df = 22, p-value = 0.233

Model df: 2.   Total lags used: 24

          Jan      Feb      Mar      Apr      May      Jun      Jul      Aug
1961 450.4224 425.7172 479.0068 492.4045 509.0550 583.3449 670.0108 667.0776
1962 495.9301 468.7289 527.4025 542.1538 560.4865 642.2823 737.7043 734.4748
          Sep      Oct      Nov      Dec
1961 558.1894 497.2078 429.8720 477.2426
1962 614.5852 547.4424 473.3034 525.4600
                       ME       RMSE        MAE        MPE      MAPE      MASE
Training set 0.0005730622 0.03504883 0.02626034 0.01098898 0.4752815 0.2169522
                   ACF1
Training set 0.01443892

16_arima.R: chart 1 of 8, drawn by the program

16_arima.R: chart 2 of 8, drawn by the program

16_arima.R: chart 3 of 8, drawn by the program

16_arima.R: chart 4 of 8, drawn by the program

16_arima.R: chart 5 of 8, drawn by the program

16_arima.R: chart 6 of 8, drawn by the program

16_arima.R: chart 7 of 8, drawn by the program

16_arima.R: chart 8 of 8, drawn by the program

In Python, python/16_arima.py:

OUTPUT

THE SERIES                     R: ts(sales, frequency = 12)
  36 monthly observations
  ▁▂▃▃▃▂▁▁▁▁▁▂▃▄▅▅▅▅▄▄▃▃▄▅▆▇▇█▇▇▇▇▆▆▆▇
  first 6: [100.0, 111.75, 119.97, 122.7, 120.26, 114.84]

DECOMPOSITION                  R: decompose(ts)
  trend (12-month centred MA), first and last known values:
    t= 6 ->  112.48
    t=29 ->  158.28
  implied slope = 1.991 per month  (series was built with 2.0)

STATIONARITY BY DIFFERENCING   R: diff(ts) ; ndiffs(ts)
  original     mean=  135.07  sd= 20.93  first-half vs second-half mean gap =   29.30
  differenced  mean=    1.70  sd=  5.60  first-half vs second-half mean gap =    1.77
  A large gap between the halves' means IS non-stationarity in the
  mean. Differencing collapses it, which is what d=1 achieves.

ACF -- RAW SERIES              R: acf(ts)
    lag  1  +0.9022  +++++++++++++++++++++++++++
    lag  2  +0.7814  +++++++++++++++++++++++
    lag  3  +0.6518  +++++++++++++++++++
    lag  4  +0.5276  +++++++++++++++
    lag  5  +0.4201  ++++++++++++
    lag  6  +0.3358  ++++++++++
    lag  7  +0.2757  ++++++++
    lag  8  +0.2351  +++++++
    lag  9  +0.2048  ++++++
    lag 10  +0.1734  +++++
    lag 11  +0.1314  +++
    lag 12  +0.0745  ++
    lag 13  +0.0055
    lag 14  -0.0667  +
  Slow, monotonic decay and nothing else. This is the classic
  signature of a TREND, and it is so dominant that the seasonality
  built into this series is completely invisible here.

ACF -- DIFFERENCED SERIES      R: acf(diff(ts))
    lag  1  +0.7879  +++++++++++++++++++++++
    lag  2  +0.3765  +++++++++++
    lag  3  -0.0879  ++
    lag  4  -0.4688  ++++++++++++++
    lag  5  -0.6824  ++++++++++++++++++++
    lag  6  -0.7071  +++++++++++++++++++++   <- MINIMUM: half a period out of phase
    lag  7  -0.5692  +++++++++++++++++
    lag  8  -0.3185  +++++++++
    lag  9  -0.0137
    lag 10  +0.2823  ++++++++
    lag 11  +0.5038  +++++++++++++++
    lag 12  +0.5931  +++++++++++++++++   <- local MAXIMUM: period-12 seasonality
    lag 13  +0.5196  +++++++++++++++
    lag 14  +0.3001  +++++++++

  💡 THE LESSON: seasonality was NOT visible in the raw ACF, because
  the trend swamped it. Only after differencing does the oscillation
  appear -- negative around lag 6, peaking again at lag 12. This is
  why the order of operations matters: difference FIRST, then read
  the ACF and PACF. Reading them on a trending series tells you
  almost nothing except 'there is a trend'.

PACF                           R: pacf(ts)
    lag  1  +0.9022
    lag  2  -0.1755
    lag  3  -0.1034
    lag  4  -0.0399
    lag  5  +0.0067
    lag  6  +0.0334
    lag  7  +0.0393
    lag  8  +0.0252

  READING THEM (Unit 5 A.4):
    ACF tails off, PACF cuts off after lag p  -> AR(p), p from PACF
    ACF cuts off after lag q, PACF tails off  -> MA(q), q from ACF
    ACF decaying slowly                       -> difference first

  raw ACF decays monotonically (trend); differenced ACF peaks at
  lag 12 and troughs at lag 6 (seasonality); slope recovered ✓

Corrected: this note said the raw ACF shows no seasonality at all, the trend dominating completely; so did the script's comment. That is true of the Python equivalent's series, not of the airline data. Also corrected: the script's comment expected a large ADF p-value on the raw series. adf.test() allows for a linear trend, and gives p below 0.01, while KPSS rejects a constant level, also p below 0.01: the series is trend-stationary.

The script's ACF after differencing is of d12, after both the ordinary and the seasonal difference. What is left is a spike at lag 1 (−0.34) and one at lag 12 (−0.39): the pattern of an MA(1) and a seasonal MA(1) term, which is the model auto.arima() then picks.

RESULT

auto.arima() chooses ARIMA(0,1,1)(0,1,1)[12] on the log series, with AIC −483.4. The Ljung-Box p of 0.233 leaves no pattern in the residuals, and the forecast for July 1962 is 738 thousand passengers.

Experiment 17 — Interactive plots with plotly

1. Question

Make the students' charts interactive with plotly.

2. Aim

Make a ggplot2 chart interactive, and draw plotly charts directly.

3. Steps

  1. Make the students data frame.
  2. Make a ggplot2 chart interactive.
  3. Draw a native plotly scatter, with hover text.
  4. Draw a bar chart of the means.

4. Programme

# =====================================================================
# Run with R 4.3.3. Rscript builds each chart but has nowhere to show it, so
# _drive_17_plotly.py runs this file with each chart saved as a web page, and
# opens each in Chromium for the screenshots on the lab page. (Until October
# 2026 R could not be installed where these labs are checked, so this file
# was desk-checked only.)
# =====================================================================
# Experiment 17: Interactive visualisations with plotly
# No Python equivalent -- this demonstrates plotly's R interface specifically.

library(plotly); library(ggplot2)

# Step 1: Make the students data frame
students <- data.frame(
  name    = c("Ananya","Bhavana","Charan","Divya","Eshwar",
              "Fiona","Gopal","Harika","Ismail","Jyothi"),
  section = c("A","A","B","B","A","C","C","B","A","C"),
  hours   = c(9, 5, 11, 4, 7, 8, 3, 10, 6, 2),
  marks   = c(85, 62, 91, 55, 74, 79, 48, 88, 68, 41))

# --- THE ONE-LINE ROUTE: convert any ggplot2 plot ---
# Step 2: Make a ggplot2 chart interactive
p <- ggplot(students, aes(x = hours, y = marks, colour = section)) +
       geom_point(size = 3) +
       labs(title = "Marks against study hours")
ggplotly(p)          # hover, zoom and pan now work. That is the whole trick.

# --- NATIVE plotly ---
# Step 3: Draw a native plotly scatter, with hover text
plot_ly(students,
        x = ~hours, y = ~marks, color = ~section,      # NOTE the ~
        type = "scatter", mode = "markers",
        marker = list(size = 12),
        text = ~paste("Name:", name, "<br>Marks:", marks),
        hoverinfo = "text") %>%
  layout(title = "Marks against study hours",
         xaxis = list(title = "Hours studied"),
         yaxis = list(title = "Marks"))

# TWO THINGS THAT CATCH PEOPLE:
#   1. plotly uses FORMULA notation (~hours) to name columns. Writing
#      x = hours looks for a variable in your environment and fails.
#   2. plotly layers chain with %>%, NOT with + . This is the reverse of
#      ggplot2, and mixing them is the commonest plotly error.

# --- BAR AND LINE ---
# Step 4: Draw a bar chart of the means
avg <- aggregate(marks ~ section, students, mean)
plot_ly(avg, x = ~section, y = ~marks, type = "bar",
        marker = list(color = "#1e7fbf"))

# --- ANIMATION: one extra argument ---
# library(gapminder)
# plot_ly(gapminder, x = ~gdpPercap, y = ~lifeExp,
#         size = ~pop, color = ~continent,
#         frame = ~year,                 # <- this creates the animation
#         type = "scatter", mode = "markers") %>%
#   layout(xaxis = list(type = "log")) %>%
#   animation_opts(frame = 1000, transition = 500, redraw = FALSE)
#
# frame = ~year is ALL an animation needs. plotly adds the play button and
# the slider automatically -- the famous Gapminder chart in six lines.

# --- RANGE SLIDER for a time series ---
# plot_ly(x = ~time(AirPassengers), y = ~AirPassengers,
#         type = "scatter", mode = "lines") %>%
#   rangeslider()

# --- EXPORT ---
# htmlwidgets::saveWidget(fig, "plot.html")

5. Execution and Results

OUTPUT

Loading required package: ggplot2

Attaching package: ‘plotly’

The following object is masked from ‘package:ggplot2’:

    last_plot

The following object is masked from ‘package:stats’:

    filter

The following object is masked from ‘package:graphics’:

    layout

[chart 1, which RStudio would show in its Viewer, saved as chart1.html]
[chart 2, which RStudio would show in its Viewer, saved as chart2.html]
[chart 3, which RStudio would show in its Viewer, saved as chart3.html]

chart 1: "Marks against study hours", 3 trace(s)
  [screenshot 1: chart 1]

chart 2: "Marks against study hours", 3 trace(s)
  [screenshot 2: chart 2]
  hovering over a point shows: Name: Ananya Marks: 85
  [screenshot 3: chart 2, hovering over a point]

chart 3: "(no title)", 1 trace(s)
  [screenshot 4: chart 3]

17_plotly.R: screenshot 1 of 4, taken while the program ran

17_plotly.R: screenshot 2 of 4, taken while the program ran

17_plotly.R: screenshot 3 of 4, taken while the program ran

17_plotly.R: screenshot 4 of 4, taken while the program ran

The screenshots are the three charts, in order, and the second again with the pointer over a point, showing its hover text. R itself prints only the packages' start-up messages.

RESULT

All three charts draw, and hovering over Ananya's point shows "Name: Ananya, Marks: 85": the hover text the script builds.

Experiment 18 — Shiny app with CSV upload

1. Question

Build a Shiny app that lets a user upload a CSV file and explore it.

2. Aim

Write a Shiny app with an upload, a reactive data source and three output tabs.

3. Steps

  1. Lay out the page: the upload, the options and three tabs.
  2. Read the uploaded file once, in a reactive.
  3. Build the column picker from the file.
  4. Fill the data, summary and plot tabs.
  5. Run the app.

THE THREE SHINY RULES

  1. Call a reactive with parentheses — data(), never data.
  2. input$x only inside a reactive context — reactive(), observe() or render*().

  3. Output IDs must match between ui and server. A typo gives a blank panel and no error message, so check spelling first.

req(input$file) is the idiomatic way to wait for an upload — it silently pauses the reactive rather than erroring on NULL.

4. Programme

# =====================================================================
# Run with R 4.3.3. _drive_18_shiny_app.py starts this app, opens it in
# Chromium, uploads a CSV and goes through its three tabs, for the
# screenshots on the lab page. (Until October 2026 R could not be installed
# where these labs are checked, so this file was desk-checked only.)
# =====================================================================
# Experiment 18: A Shiny app that lets users upload a CSV file
# No Python equivalent -- this demonstrates the Shiny framework itself.
#
# Run with:  shiny::runApp("18_shiny_app.R")
# or paste into RStudio and click "Run App".

library(shiny); library(ggplot2); library(dplyr)

# Step 1: Lay out the page: the upload, the options and three tabs
ui <- fluidPage(
  titlePanel("CSV Explorer"),

  sidebarLayout(
    sidebarPanel(
      fileInput("file", "Upload a CSV file", accept = ".csv"),
      checkboxInput("header", "File has a header row", TRUE),
      uiOutput("column_picker"),          # built dynamically from the file
      sliderInput("bins", "Histogram bins:", min = 5, max = 50, value = 20),
      hr(),
      helpText("Upload any CSV. The app lists its columns and plots whichever",
               "numeric column you choose.")
    ),

    mainPanel(
      tabsetPanel(
        tabPanel("Data",    tableOutput("preview")),
        tabPanel("Summary", verbatimTextOutput("summary")),
        tabPanel("Plot",    plotOutput("histogram"))
      )
    )
  )
)

server <- function(input, output, session) {

  # ONE reactive, shared by every output. Written this way the file is read
  # ONCE per upload; copying read.csv() into each render*() would read it
  # three times.
  # Step 2: Read the uploaded file once, in a reactive
  data <- reactive({
    req(input$file)                        # wait until a file is uploaded
    read.csv(input$file$datapath, header = input$header,
             stringsAsFactors = FALSE)
  })

  # Build the column dropdown from the uploaded file's numeric columns.
  # Step 3: Build the column picker from the file
  output$column_picker <- renderUI({
    req(data())
    nums <- names(data())[sapply(data(), is.numeric)]
    selectInput("column", "Numeric column to plot:", choices = nums)
  })

  # Step 4: Fill the data, summary and plot tabs
  output$preview <- renderTable({
    head(data(), 10)                       # NOTE the parentheses: data()
  })

  output$summary <- renderPrint({
    summary(data())
  })

  output$histogram <- renderPlot({
    req(input$column)
    ggplot(data(), aes(x = .data[[input$column]])) +
      geom_histogram(bins = input$bins, fill = "#1e7fbf", colour = "white") +
      labs(title = paste("Distribution of", input$column),
           x = input$column) +
      theme_minimal()
  })
}

# Step 5: Run the app
shinyApp(ui = ui, server = server)

# THE THREE RULES THAT CAUSE MOST SHINY BUGS:
#   1. Call a reactive WITH parentheses: data(), never data.
#   2. input$x can only be read inside a reactive context -- reactive(),
#      observe() or render*(). At the top level of server() it errors.
#   3. Every output ID must match: plotOutput("histogram") in the UI pairs
#      with output$histogram in the server. A typo gives a blank panel and
#      NO error message, so check spelling first when nothing appears.
#
# req() is the idiomatic way to wait for an input: it silently stops the
# reactive until its argument is available, instead of erroring on NULL.

5. Execution and Results

OUTPUT

started the app with shiny::runApp('18_shiny_app.R')
page title: CSV Explorer
before an upload, the Data tab shows 0 table(s)

uploaded students.csv; the Data tab shows the columns ['name', 'section', 'hours', 'marks'], 10 rows
  first row: ['Ananya', 'A', '9', '85']
  the column picker offers the numeric columns: ['hours', 'marks']
  [screenshot 1: the Data tab, after the upload]

the Summary tab shows summary(data()):
       name             section              hours           marks
   Length:10          Length:10          Min.   : 2.00   Min.   :41.00
   Class :character   Class :character   1st Qu.: 4.25   1st Qu.:56.75
   Mode  :character   Mode  :character   Median : 6.50   Median :71.00
                                         Mean   : 6.50   Mean   :69.10
                                         3rd Qu.: 8.75   3rd Qu.:83.50
                                         Max.   :11.00   Max.   :91.00
  [screenshot 2: the Summary tab]

the Plot tab draws a histogram of hours
  [screenshot 3: the Plot tab, hours]
chose the column marks and 8 bins; the histogram is redrawn
  [screenshot 4: the Plot tab, marks in 8 bins]

stopped the app

18_shiny_app.R: screenshot 1 of 4, taken while the program ran

18_shiny_app.R: screenshot 2 of 4, taken while the program ran

18_shiny_app.R: screenshot 3 of 4, taken while the program ran

18_shiny_app.R: screenshot 4 of 4, taken while the program ran

Before the upload the Data tab is empty: req() holds every output back. The summary is R's summary() of the uploaded file, printed by the app.

RESULT

The app reads the uploaded file, lists its two numeric columns, summarises it, and redraws the histogram when the column or the number of bins changes.


Lab exam tips

  1. Install R and RStudio at home. You cannot revise R from notes alone.
  2. set.seed() before anything random — clustering, sampling, simulation. Without it your results are not reproducible, which is a fault in itself.

  3. str() and summary() first, always. Know your data before analysing it.

  4. Comment the interpretation, not the syntax. # r = 0.99, very strong positive earns marks; # compute correlation does not.

  5. Label every plot — title, axis labels, legend.

  6. Expect a viva. "Why var.equal = TRUE?", "what happens without scale()?", "why difference before reading the ACF?"

Written-out instructions

This part of the lab is a written procedure rather than a program.

LAB OVERVIEW

R and the Python equivalents

One topic, one language, one page

The same experiments again, split by language, so a page can be reached by the thing it teaches rather than by its number.

R

Mean, Median, Mode, Variance and Standard Deviation in R

PYTHON

Mean, Median, Mode, Variance and Standard Deviation in Python

R

Binomial, Normal and Poisson Distributions in R

PYTHON

Binomial, Normal and Poisson Distributions in Python

R

t-test and Chi-Square Test in R

PYTHON

t-test and Chi-Square Test in Python

R

Correlation and Linear Regression in R

PYTHON

Correlation and Linear Regression in Python

R

Exploratory Data Analysis in R

PYTHON

Exploratory Data Analysis in Python

R

Scaling, Normalisation and Encoding in R

PYTHON

Scaling, Normalisation and Encoding in Python

R

Variables, Control Structures and Functions in R

R

Reading and Writing CSV, Excel, JSON and XML in R

PYTHON

Reading and Writing CSV, Excel, JSON and XML in Python

R

Data Wrangling in R

PYTHON

Data Wrangling in Python

R

Missing Data and Outlier Detection in R

PYTHON

Missing Data and Outlier Detection in Python

R

Working with Dates and Times in R

PYTHON

Working with Dates and Times in Python

R

Plotting with ggplot2 in R

R

K-Means Clustering in R

PYTHON

K-Means Clustering in Python

R

Confusion Matrix, Accuracy and ROC in R

PYTHON

Confusion Matrix, Accuracy and ROC in Python

R

Text Mining and Word Frequency in R

PYTHON

Text Mining and Word Frequency in Python

R

Time Series Forecasting with ARIMA in R

PYTHON

Time Series Forecasting with ARIMA in Python

R

Interactive Charts with plotly in R

R

Building a Shiny App in R