Please do not start a blank .Rmd — work directly in
this template, since every question is already laid out
for you here.
author line
and in the names table below.q1a then
q1b — the same pattern continues later (q6a,
q10a, q16a, and so on). These chunk names
matter for marking, so please do not delete or rename them.r, then the object name
(for example n_rows) — rather than typing the digits by
hand.data/ folder so
data/spotify_tracks_etf2121.csv loads.Cmd/Ctrl + Shift + K), and submit both the
knitted .html and this filled-in .Rmd.Your knitted file needs to show the R code for every task, not just
the numbers — hidden code (echo = FALSE) scores zero for
that task.
ETF2121: Q1–Q15 (skip Part B) and Part C. ETF5912: Q1–Q18 and Part C.
This assignment, the recording, and the FeedbackFruits group member evaluation are due Friday 23 October 2026, 11.55pm.
| Name | Student ID | Unit (ETF2121 or ETF5912) |
|---|---|---|
Imagine you are briefing a Spotify manager using a course extract of tracks. The two tasks in front of you look related at first glance, but they are asking genuinely different questions.
Problem 1: How popular is a song, on a 0–100 scale? You will answer this with linear regression.
Problem 2: Is a song in the top 10% of this file — yes or no? You will answer this with logistic regression.
Clean the data before you model it — a model built on a messy table will mislead you long before it reaches a manager. Every number in your write-up must come from your own R output, and your recommendation should be something a manager could genuinely act on.
Dynamite by BTS is a reminder that a “top song” is a business outcome worth defining carefully — now test what this dataset can actually tell you. 🎵😉
You will work through the template alongside
spotify_tracks_etf2121.csv. Complete both problems, then
record a 5-minute briefing summarising what you
found.
| Who | Written questions | Recording |
|---|---|---|
| ETF2121 | Q1–Q15 (skip Part B) | Part C |
| ETF5912 | Q1–Q18 | Part C |
| Part | What we mark | Questions | ETF2121 | ETF5912 |
|---|---|---|---|---|
| A | Inspect, clean, plot, split, fit both models, read coefficients with intervals, and brief a manager | Q1–Q15 | 90 | 90 |
| B | ETF5912 only: a regression tree for Problem 1 and a classification tree for Problem 2; 19 marks plus an optional 1-mark extension bonus, capped at 20 | Q16–Q18 | — | 20 |
| C | One 5-minute group recording: a briefing of the two problems, not a tour of the code | — | 10 | 10 |
| Total | 100 | 120 |
ETF5912 groups, and mixed groups, are marked out of 120 because Part B is extra work. ETF2121 groups skip Part B and are marked out of 100.
The data lives in data/spotify_tracks_etf2121.csv, in
the data/ folder next to the template. It is a
sample of Spotify tracks curated for this unit, not the
full catalogue, and each row represents one song.
Gildas Le Drogoff collected the original data in February 2026, and we added a simple genre column drawn from Maharshi Pandya’s list.
Three limitations of this data will matter — keep them in mind as you work, and return to them in Q15:
popularity reflects how well-known a song was in
mid-2026 — a quiet track here may have been a hit last
year, or may yet become one next month.main_genre is a simple label, not a
musical style: the group other simply means we could not
place the track in pop, rock, hip-hop, and so on.| Column | Meaning |
|---|---|
track_id |
Spotify track id |
track_name |
Song title |
artist_name |
Main artist |
album_name |
Album title |
release_year |
Album release year (2015–2025 in this file) |
popularity |
Track popularity, 0–100 |
artist_popularity |
Artist popularity, 0–100 |
explicit |
Explicit lyrics (TRUE / FALSE) |
duration_min |
Length in minutes |
danceability, energy,
valence, acousticness,
speechiness, instrumentalness,
liveness |
Audio features, usually 0–1 |
loudness |
Loudness in dB (usually negative) |
tempo |
Tempo in BPM |
key |
Pitch class 0–11 (-1 if unknown) |
mode |
1 = major, 0 = minor |
main_genre |
Genre group: pop, hip-hop,
dance, rock, indie,
latin, r-n-b, country,
metal, other |
The models use popularity (or
is_top_song) as the outcome and these predictors:
danceability, energy, valence,
acousticness, speechiness,
instrumentalness, loudness,
tempo, explicit, main_genre,
release_year. Leave liveness,
artist_popularity, duration_min,
key, and mode out of the models.
Use the same training set and test set for every model, including Part B.
90 marks (Q1–Q15). Write your R in the named chunk under each part, then fill in the blanks using your own output.
Open the file, confirm what one row is, then clean it. A model built on duplicate songs or blank titles is not something you will want to brief to a manager.
Import data/spotify_tracks_etf2121.csv and store it as
raw.
raw should have 20,128 rows and
21 columns. If not, you have the wrong file — fix Q1
before going on.
(a) (2 marks) How many rows and columns are there?
What type is each of track_id, popularity,
explicit, and main_genre? Is
release_year stored as a number or a date?
Write your code in the q1a chunk, then record your
answer below.
library(readr)
library(dplyr)
library(ggplot2)
# Import the file, then inspect its size and column types.
raw <- read_csv("spotify_tracks_etf2121.csv", show_col_types = FALSE)
n_rows <- nrow(raw)
n_cols <- ncol(raw)
class(raw$track_id)
## [1] "character"
class(raw$popularity)
## [1] "numeric"
class(raw$explicit)
## [1] "logical"
class(raw$main_genre)
## [1] "character"
class(raw$release_year)
## [1] "numeric"
Answer (a): 20128 rows, 21 columns.
track_idcharacter. popularity numeric.
explicit logical. main_genre character.
release_year is numeric.
(b) (2 marks) Inspect the first eight rows. What is the first song (title and artist)? In one sentence, state what one row represents.
Write your code in the q1b chunk, then record your
answer below.
head(raw, 8)
| track_id | track_name | artist_name | album_name | release_year | popularity | artist_popularity | explicit | duration_min | danceability | energy | key | loudness | mode | speechiness | acousticness | instrumentalness | liveness | valence | tempo | main_genre |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 538gYspJYGYP5x7yWGkkb5 | Truckin’ - Live | Grateful Dead | Arrows Of Neon (Live Autzen August 21,1993) | 2023 | 1 | 67 | FALSE | 7.054700 | 0.489 | 0.7040 | 9 | -10.143 | 1 | 0.0446 | 0.752000 | 0.009980 | 0.6000 | 0.735 | 128.016 | rock |
| 3AfKN7WWrw96DZH1ob9rMb | Nightmare | Unresolved | Escape the Nightmare | 2017 | 10 | 47 | FALSE | 3.573600 | 0.518 | 0.9930 | 1 | -4.469 | 1 | 0.2430 | 0.129000 | 0.755000 | 0.0782 | 0.163 | 150.089 | dance |
| 6lNNnVhr3p29wo3z2QHwyR | WALKING ON WATER | Amir Obe | after. | 2023 | 1 | 46 | FALSE | 2.273683 | 0.269 | 0.6390 | 1 | -8.271 | 1 | 0.0501 | 0.000722 | 0.003870 | 0.3250 | 0.125 | 190.230 | r-n-b |
| 5lHsjSxllbUbW91v12cTnt | Swim Beneath My Skin | Counterparts | You’re Not You Anymore | 2017 | 13 | 51 | FALSE | 2.911467 | 0.250 | 0.9940 | 0 | -1.595 | 0 | 0.2030 | 0.001040 | 0.000000 | 0.5160 | 0.188 | 178.098 | metal |
| 3agZIM9npZeWJWEoBsEvaz | Allegretto in C Minor, WoO 53 - Excerpt | Ludwig van Beethoven | “A Classical Winter”: The Piano | 2025 | 11 | 73 | FALSE | 1.607550 | 0.557 | 0.0178 | 0 | -27.481 | 0 | 0.0517 | 0.990000 | 0.911000 | 0.1220 | 0.160 | 105.154 | other |
| 3Ltjz9QblnRnzYekexV1By | Toss A Coin To Your Witcher (From “The Witcher series”) [Lofi Beat] | Celestial Alignment | Chillhop Covers, Vol. 1 | 2020 | 19 | 43 | FALSE | 2.874700 | 0.797 | 0.0619 | 10 | -24.808 | 0 | 0.2080 | 0.622000 | 0.886000 | 0.1300 | 0.336 | 100.105 | other |
| 6IleBCr2a59f47541Opgyg | Banna Bannada Loka (From “Ekangi”) | Shankar Mahadevan | Sad Feeling Songs | 2017 | 8 | 77 | FALSE | 5.146417 | 0.513 | 0.5810 | 4 | -5.108 | 0 | 0.0301 | 0.695000 | 0.000155 | 0.1880 | 0.349 | 94.717 | pop |
| 5a5By7s1KIPqDYZyV8UrN9 | Tchaikovsky: Violin Concerto in D Major, Op. 35: I. Allegro moderato, Pt. 3 | Pyotr Ilyich Tchaikovsky | Violin Concertos: Tchaikovsky, Bruch, Mozart, Prokofiev, Stravinsky, Lalo | 2024 | 10 | 70 | FALSE | 1.999767 | 0.373 | 0.2050 | 9 | -18.626 | 1 | 0.0448 | 0.928000 | 0.608000 | 0.5710 | 0.290 | 126.578 | other |
Answer (b): The first song is “Truckin’ - Live” by Grateful Dead. One row is a song record..
(a) (2 marks) Report the smallest and largest
release_year values and the number of distinct years. How
many main_genre labels are there, and which label has the
most rows?
Write your code in the q2a chunk, then record your
answer below.
library(dplyr)
year_summary <- raw |>
summarise(
first_year = min(release_year),
last_year = max(release_year),
n_years = n_distinct(release_year)
)
year_summary
| first_year | last_year | n_years |
|---|---|---|
| 2015 | 2025 | 11 |
genre_counts_raw <- raw |>
count(main_genre) |>
arrange(desc(n))
genre_counts_raw
| main_genre | n |
|---|---|
| other | 9089 |
| dance | 4054 |
| latin | 1948 |
| pop | 1705 |
| rock | 1374 |
| r-n-b | 612 |
| metal | 390 |
| indie | 346 |
| hip-hop | 315 |
| country | 295 |
year_min <- year_summary$first_year
year_max <- year_summary$last_year
n_years <- year_summary$n_years
n_genres <- nrow(genre_counts_raw)
largest_genre <- genre_counts_raw$main_genre[1]
largest_genre_n <- genre_counts_raw$n[1]
Answer (a): Years 2015 to 2025 (11 years). 10 genre labels. Largest: other, 9089 rows.
(b) (2 marks) Which columns contain missing values, and how many missing values does each contain? Do any of the modelling columns listed above contain missing values? Yes / no.
Write your code in the q2b chunk, then record your
answer below.
library(ggplot2)
model_columns <- c(
"popularity",
"danceability",
"energy",
"valence",
"acousticness",
"speechiness",
"instrumentalness",
"loudness",
"tempo",
"explicit",
"main_genre",
"release_year"
)
missing_counts <- colSums(is.na(raw))
missing_nonzero <- missing_counts[missing_counts > 0]
missing_nonzero
## track_name artist_name
## 25 25
model_missing <- sum(missing_counts[model_columns])
model_missing
## [1] 0
Answer (b): Missing columns and counts: ______. Missing values in modelling columns: yes / no.
The same track_id twice is the same song counted twice.
A row with no title or artist cannot be named later.
Drop rows with a missing track_name or
artist_name, keep one row per
track_id, and save the result as tracks. From
this point on, work only with tracks.
tracks is readyYou should get 19,998 unique track_id
values, 130 extra duplicate rows, 50
rows with a missing title or artist, and 19,998 songs
remaining in tracks. If you do not, stop and fix Q3 before
Q4.
(a) (2 marks) Using raw, report the
number of unique track_id values, extra duplicate rows,
rows with a missing title or artist, and songs remaining after
cleaning.
Write your code in the q3a chunk, then record your
answer below.
unique_ids <- n_distinct(raw$track_id)
extra_copies <- nrow(raw) - unique_ids
missing_name <- sum(
is.na(raw$track_name) |
is.na(raw$artist_name)
)
tracks <- raw |>
filter(
!is.na(track_name),
!is.na(artist_name)
) |>
distinct(
track_id,
.keep_all = TRUE
)
n_clean <- nrow(tracks)
unique_ids
## [1] 19998
extra_copies
## [1] 130
missing_name
## [1] 50
n_clean
## [1] 19998
Answer (a): Unique ids ______. Extra copies ______. Missing name ______. After cleaning ______.
(b) (2 marks) Using raw, show
one duplicated track_id and
three rows with a missing title or artist. Does the
duplicated ID refer to the same song? Yes /
no.
Write your code in the q3b chunk, then record your
answer below.
duplicate_id <- raw$track_id[
which(duplicated(raw$track_id))[1]
]
dup_example <- raw |>
filter(track_id == duplicate_id) |>
select(
track_id,
track_name,
artist_name
)
dup_example
| track_id | track_name | artist_name |
|---|---|---|
| 72AxRjmhnkZ461Bm4wysYa | Serenade for String Orchestra, Op. 48, TH 48: II. Walzer: Moderato - Tempo di Valse | Pyotr Ilyich Tchaikovsky |
| 72AxRjmhnkZ461Bm4wysYa | Serenade for String Orchestra, Op. 48, TH 48: II. Walzer: Moderato - Tempo di Valse | Pyotr Ilyich Tchaikovsky |
missing_example <- raw |>
filter(
is.na(track_name) |
is.na(artist_name)
) |>
select(
track_id,
track_name,
artist_name
) |>
head(3)
missing_example
| track_id | track_name | artist_name |
|---|---|---|
| 4GmRnc9J8GBBkVHUtihE3Z | Vuurwerk | NA |
| 4ZQMLMnq96EBgdJCiwChsP | Música Mágica de Olas para Dormir pt. 4 | NA |
| 440HhZPx47InubN85le1JO | NA | Kaleem Taylor |
Answer (b): Duplicated id ______. Same song: yes / no, because ______. The three missing-name rows shown are ______.
Spotify scores popularity from 0 to 100. Inexperienced data analysts might mistakenly treat 70 as “a hit”. That cutoff would be defensible only if a meaningful share of this file actually reached 70.
Use the cleaned data in tracks.
(a) (4 marks) Report the mean and median
popularity. Create a histogram of song popularity and a bar
chart showing the number of songs in each main_genre. Does
the histogram show most songs clustering around 70, or mostly lower
popularity scores? Which genre appears most often?
Write your code in the q4a chunk, then record your
answer below.
pop_summary <- tracks |>
summarise(
mean_popularity = mean(popularity),
median_popularity = median(popularity)
)
pop_summary
| mean_popularity | median_popularity |
|---|---|
| 13.9883 | 8 |
pop_mean <- pop_summary$mean_popularity
pop_median <- pop_summary$median_popularity
ggplot(
tracks,
aes(x = popularity)
) +
geom_histogram(
binwidth = 5,
boundary = -0.5
) +
labs(
title = "Distribution of song popularity",
x = "Popularity score",
y = "Number of songs"
) +
theme_minimal()
genre_counts <- tracks |>
count(main_genre) |>
arrange(desc(n))
genre_counts
| main_genre | n |
|---|---|
| other | 9034 |
| dance | 4025 |
| latin | 1938 |
| pop | 1691 |
| rock | 1361 |
| r-n-b | 610 |
| metal | 387 |
| indie | 344 |
| hip-hop | 314 |
| country | 294 |
ggplot(
genre_counts,
aes(
x = reorder(main_genre, n),
y = n
)
) +
geom_col() +
coord_flip() +
labs(
title = "Number of songs by genre",
x = "Genre",
y = "Number of songs"
) +
theme_minimal()
largest_genre_clean <- genre_counts$main_genre[1]
Answer (a): Mean ______. Median ______. The mass of the histogram is ______. Largest genre: ______.
(b) (2 marks) What percentage of songs have
popularity >= 70? In a few sentences, explain why 70 is
an inappropriate yes/no cutoff for this dataset.
Write your code in the q4b chunk, then record your
answer below.
share70 <- mean(
tracks$popularity >= 70
)
share70_percent <- 100 * share70
share70_percent
## [1] 1.785179
Answer (b): Songs with
popularity >= 70: ______%. A cutoff of 70 is
inappropriate because ______.
In a linear model, each coefficient represents the impact of one variable while keeping all others constant, but this interpretation becomes difficult if two predictors are highly correlated and tend to change together.
(a) (2 marks) Calculate and display the correlation
matrix for the numeric modelling predictors only
(danceability, energy, valence,
acousticness, speechiness,
instrumentalness, loudness,
tempo, release_year), excluding
popularity. Round all correlations to two decimal places to
assess potential multicollinearity between predictors.
One of the assumptions of the linear model is that the predictors are not perfectly correlated with each other. Perfect multicollinearity is when one predictor is an exact duplicate of another. When predictors are highly correlated but not identical, the model can still fit, but the coefficient estimates become less precise: standard errors get larger and interpreting “holding the other fixed” becomes tricky.
Write your code in the q5a chunk, then record your
answer below.
num_predictors <- c(
"danceability",
"energy",
"valence",
"acousticness",
"speechiness",
"instrumentalness",
"loudness",
"tempo",
"release_year"
)
cor_matrix <- tracks |>
select(all_of(num_predictors)) |>
cor()
round(cor_matrix, 2)
## danceability energy valence acousticness speechiness
## danceability 1.00 0.35 0.55 -0.39 0.21
## energy 0.35 1.00 0.35 -0.77 0.11
## valence 0.55 0.35 1.00 -0.22 0.12
## acousticness -0.39 -0.77 -0.22 1.00 -0.09
## speechiness 0.21 0.11 0.12 -0.09 1.00
## instrumentalness -0.36 -0.36 -0.39 0.32 -0.19
## loudness 0.50 0.79 0.40 -0.66 0.09
## tempo 0.10 0.28 0.14 -0.25 0.03
## release_year 0.04 -0.03 0.01 0.03 0.01
## instrumentalness loudness tempo release_year
## danceability -0.36 0.50 0.10 0.04
## energy -0.36 0.79 0.28 -0.03
## valence -0.39 0.40 0.14 0.01
## acousticness 0.32 -0.66 -0.25 0.03
## speechiness -0.19 0.09 0.03 0.01
## instrumentalness 1.00 -0.58 -0.13 0.02
## loudness -0.58 1.00 0.27 -0.02
## tempo -0.13 0.27 1.00 0.00
## release_year 0.02 -0.02 0.00 1.00
largest_abs_cor <- max(
abs(cor_matrix[upper.tri(cor_matrix)])
)
largest_abs_cor
## [1] 0.7864584
Answer (a): The displayed matrix is the nine
predictors (no popularity). The largest |r| among
them is ______. That is high / not perfect, so the slopes would be
______.
(b) (2 marks) Which pair of predictors has the largest absolute correlation?
Write your code in the q5b chunk, then record your
answer below.
pairs <- which(
upper.tri(cor_matrix),
arr.ind = TRUE
)
best_pair <- pairs[
which.max(
abs(cor_matrix[pairs])
),
]
pair_names <- colnames(cor_matrix)[best_pair]
pair_r <- cor_matrix[
best_pair[1],
best_pair[2]
]
pair_names
## [1] "energy" "loudness"
pair_r
## [1] 0.7864584
Answer (b): Among predictors, ______ and ______ have the largest absolute correlation (r = ______).
Call a song a top song if it sits in the top
10% of tracks — at or above the 90th percentile of
popularity.
top_cut should be 34. After
set.seed(2121) and a stratified 70/30 split,
train should have 13,997 rows and
test 6,001 rows, with about
10.5% top songs in each. If you do not get these, stop
and fix Q6 before Q7.
(a) (2 marks) Calculate and store the 90th
percentile of popularity as top_cut, then define
is_top_song as popularity >= top_cut. What
is the value of top_cut?
Write your code in the q6a chunk, then record your
answer below.
top_cut <- quantile(
tracks$popularity,
probs = 0.90
) |>
unname()
tracks <- tracks |>
mutate(
is_top_song = popularity >= top_cut,
main_genre = factor(main_genre),
explicit = factor(
explicit,
levels = c(FALSE, TRUE)
)
)
top_cut
## [1] 34
tracks |>
count(is_top_song)
| is_top_song | n |
|---|---|
| FALSE | 17891 |
| TRUE | 2107 |
Answer (a): top_cut = ______.
(b) (4 marks) Create a reproducible 70%
training set and 30% test set, stratified by
is_top_song. Run set.seed(2121) immediately
before sampling, and store the sets as train and
test. Report the number of rows and the top-song proportion
in each set. Reuse this exact split
for every subsequent model, including Part B; do not split the data
again.
Write your code in the q6b chunk, then record your
answer below.
non_top_rows <- which(
!tracks$is_top_song
)
top_rows <- which(
tracks$is_top_song
)
set.seed(2121)
non_top_train <- sample(
non_top_rows,
floor(
0.70 * length(non_top_rows)
)
)
top_train <- sample(
top_rows,
floor(
0.70 * length(top_rows)
)
)
train_idx <- c(
non_top_train,
top_train
)
train <- tracks[
train_idx,
]
test <- tracks[
-train_idx,
]
train_share <- mean(
train$is_top_song
)
test_share <- mean(
test$is_top_song
)
train |>
summarise(
n_songs = n(),
top_share = mean(is_top_song)
)
| n_songs | top_share |
|---|---|
| 13997 | 0.1053083 |
test |>
summarise(
n_songs = n(),
top_share = mean(is_top_song)
)
| n_songs | top_share |
|---|---|
| 6001 | 0.1054824 |
Answer (b): Training set: ______ rows, ______ top-song share. Test set: ______ rows, ______ top-song share. I will reuse these exact objects because ______.
Now you have a training set and a test set. Fit models on the training set and evaluate them on the test set.
Predict a 0–100 popularity score. First ask whether the line is better than no model at all.
Unless a later question tells you to drop a variable, use:
popularity ~ danceability + energy + valence + acousticness + speechiness + instrumentalness + loudness + tempo + explicit + main_genre + release_year
Fit the model on the training set. Check the model on the test set.
A manager will want to know if including the audio features actually improves predictions. Here, no model means always predicting the training-set mean popularity for every song.
(a) (2 marks) Establish a no-model
baseline: report the mean training-set popularity,
use that value as the prediction for every test-set song, and calculate
the resulting test-set RMSE. Comment on the size of this error on a
0–100 scale.
Write your code in the q7a chunk, then record your
answer below.
baseline_mean <- mean(train$popularity)
baseline_pred <- rep(baseline_mean, nrow(test))
baseline_rmse <- sqrt(
mean((test$popularity - baseline_pred)^2)
)
baseline_mean
## [1] 13.95642
baseline_rmse
## [1] 15.32129
Answer (a): Mean 13.96. No-model test set RMSE 15.32. How large: This is a fairly large error on a 0 to 100 popularity scale.
(b) (4 marks) Fit the linear model on the training
set and print its summary. Report the test-set RMSE and test-set \(R^2\). From the last line of
summary(), report the F-statistic and its p-value. In one
sentence, state whether the audio-feature model as a whole beats
predicting the mean.
Write your code in the q7b chunk, then record your
answer below.
linear_full <- lm(
popularity ~ danceability + energy + valence + acousticness +
speechiness + instrumentalness + loudness + tempo +
explicit + main_genre + release_year,
data = train
)
summary(linear_full)
##
## Call:
## lm(formula = popularity ~ danceability + energy + valence + acousticness +
## speechiness + instrumentalness + loudness + tempo + explicit +
## main_genre + release_year, data = train)
##
## Residuals:
## Min 1Q Median 3Q Max
## -30.672 -9.707 -3.865 5.833 80.915
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) -8.622e+02 7.822e+01 -11.024 < 2e-16 ***
## danceability 5.646e+00 8.951e-01 6.308 2.92e-10 ***
## energy -4.066e+00 8.844e-01 -4.597 4.32e-06 ***
## valence -5.256e+00 6.010e-01 -8.745 < 2e-16 ***
## acousticness -1.736e+00 5.690e-01 -3.051 0.00229 **
## speechiness -5.498e+00 1.144e+00 -4.807 1.55e-06 ***
## instrumentalness -2.971e+00 4.408e-01 -6.739 1.66e-11 ***
## loudness 4.042e-01 3.386e-02 11.939 < 2e-16 ***
## tempo -2.412e-03 4.297e-03 -0.561 0.57456
## explicitTRUE 4.775e+00 4.241e-01 11.257 < 2e-16 ***
## main_genredance -5.831e+00 1.092e+00 -5.339 9.48e-08 ***
## main_genrehip-hop 2.834e+00 1.438e+00 1.970 0.04881 *
## main_genreindie -2.061e-01 1.411e+00 -0.146 0.88390
## main_genrelatin -1.234e+00 1.119e+00 -1.103 0.27002
## main_genremetal -2.866e+00 1.424e+00 -2.012 0.04423 *
## main_genreother -5.139e+00 1.071e+00 -4.800 1.61e-06 ***
## main_genrepop -1.173e+00 1.129e+00 -1.039 0.29876
## main_genrer-n-b -3.165e+00 1.267e+00 -2.498 0.01249 *
## main_genrerock -3.726e+00 1.146e+00 -3.252 0.00115 **
## release_year 4.394e-01 3.873e-02 11.345 < 2e-16 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 14.43 on 13977 degrees of freedom
## Multiple R-squared: 0.1076, Adjusted R-squared: 0.1064
## F-statistic: 88.72 on 19 and 13977 DF, p-value: < 2.2e-16
pred_linear_full <- predict(linear_full, newdata = test)
linear_full_rmse <- sqrt(
mean((test$popularity - pred_linear_full)^2)
)
linear_full_r2 <- 1 -
sum((test$popularity - pred_linear_full)^2) /
sum((test$popularity - mean(test$popularity))^2)
f_stats <- summary(linear_full)$fstatistic
f_value <- unname(f_stats["value"])
f_p <- pf(
f_stats["value"],
f_stats["numdf"],
f_stats["dendf"],
lower.tail = FALSE
)
rmse_improvement <- baseline_rmse - linear_full_rmse
linear_full_rmse
## [1] 14.55398
linear_full_r2
## [1] 0.09761048
f_value
## [1] 88.71749
f_p
## value
## 0
Answer (b): Test set RMSE 14.55. Test set \(R^2\) 0.098. F = 88.72. p <2e-16. The bundle does beat the mean. How good: the model has limited predictive power because it explains only about 9.8% of the variation in popularity on unseen songs.
(c) (2 marks) Compare the linear model’s test-set RMSE with the no-model baseline’s test-set RMSE. In two sentences, state whether the model is better and whether the improvement is large enough to matter on a 0–100 scale.
Answer (c): The linear model does beat the baseline: RMSE changes from 15.32 to 14.55, an improvement of 0.77 popularity points. This is not large enough to matter because the reduction in prediction error is less than one popularity point on a 0 to 100 scale.
You already saw that overlap in Q5. Keeping both makes each coefficient “holding the other fixed,” which is hard to brief.
(a) (2 marks) Using the model from Q7, report the
coefficients for energy and loudness. Do they
have the same sign? Yes / no.
Write your code in the q8a chunk, then record your
answer below.
energy_full <- coef(linear_full)["energy"]
loudness_full <- coef(linear_full)["loudness"]
same_sign <- sign(energy_full) == sign(loudness_full)
energy_full
## energy
## -4.065976
loudness_full
## loudness
## 0.4042363
same_sign
## energy
## FALSE
Answer (a): Energy -4.066. Loudness 0.404. Same sign: no.
(b) (2 marks) Remove loudness and refit
the model. Report the energy coefficient and the new
training-set adjusted \(R^2\). In one
sentence, identify the model you will use from this point (the Q7 model
or the model without loudness) and justify your choice. A
nearly unchanged adjusted \(R^2\) is a
valid reason. Use that chosen model in Q9–Q11.
Write your code in the q8b chunk, then record your
answer below.
linear_reduced <- lm(
popularity ~ danceability + energy + valence + acousticness +
speechiness + instrumentalness + tempo +
explicit + main_genre + release_year,
data = train
)
summary(linear_reduced)
##
## Call:
## lm(formula = popularity ~ danceability + energy + valence + acousticness +
## speechiness + instrumentalness + tempo + explicit + main_genre +
## release_year, data = train)
##
## Residuals:
## Min 1Q Median 3Q Max
## -29.957 -9.625 -4.046 5.829 81.950
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) -8.602e+02 7.861e+01 -10.942 < 2e-16 ***
## danceability 8.536e+00 8.661e-01 9.856 < 2e-16 ***
## energy 1.905e+00 7.331e-01 2.599 0.009367 **
## valence -5.582e+00 6.034e-01 -9.251 < 2e-16 ***
## acousticness -1.818e+00 5.719e-01 -3.180 0.001478 **
## speechiness -7.242e+00 1.140e+00 -6.352 2.19e-10 ***
## instrumentalness -5.247e+00 3.994e-01 -13.137 < 2e-16 ***
## tempo 1.766e-03 4.305e-03 0.410 0.681573
## explicitTRUE 4.872e+00 4.262e-01 11.432 < 2e-16 ***
## main_genredance -5.917e+00 1.098e+00 -5.391 7.11e-08 ***
## main_genrehip-hop 2.688e+00 1.445e+00 1.860 0.062918 .
## main_genreindie -8.409e-02 1.418e+00 -0.059 0.952723
## main_genrelatin -1.302e+00 1.124e+00 -1.158 0.247006
## main_genremetal -2.609e+00 1.431e+00 -1.823 0.068337 .
## main_genreother -5.563e+00 1.075e+00 -5.173 2.33e-07 ***
## main_genrepop -1.147e+00 1.134e+00 -1.011 0.311933
## main_genrer-n-b -2.938e+00 1.273e+00 -2.308 0.021020 *
## main_genrerock -3.820e+00 1.152e+00 -3.317 0.000912 ***
## release_year 4.341e-01 3.892e-02 11.154 < 2e-16 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 14.5 on 13978 degrees of freedom
## Multiple R-squared: 0.09852, Adjusted R-squared: 0.09736
## F-statistic: 84.87 on 18 and 13978 DF, p-value: < 2.2e-16
energy_reduced <- coef(linear_reduced)["energy"]
adj_r2_full <- summary(linear_full)$adj.r.squared
adj_r2_reduced <- summary(linear_reduced)$adj.r.squared
adj_r2_change <- adj_r2_reduced - adj_r2_full
pred_linear_reduced <- predict(linear_reduced, newdata = test)
linear_reduced_rmse <- sqrt(
mean((test$popularity - pred_linear_reduced)^2)
)
energy_reduced
## energy
## 1.905216
adj_r2_reduced
## [1] 0.09735903
adj_r2_change
## [1] -0.00904916
Answer (b): I dropped loudness. Energy coefficient 1.905. Training set adjusted \(R^2\) 0.097. I will use the model without loudness because removing loudness changes adjusted R squared by only 0.009, while making the energy coefficient easier to interpret.
Use the model you chose in Q8.
(a) (3 marks) Create a residuals-versus-fitted plot using the training set. Then combine the training-set fitted values with the test-set predictions into one set of predicted popularity scores. Report the minimum and maximum of that combined set, and count how many values fall below 0 or exceed 100. Explain the residual pattern in a few sentences, including whether the spread stays even or fans out.
Write your code in the q9a chunk, then record your
answer below.
residual_data <- data.frame(
fitted = fitted(linear_reduced),
residual = resid(linear_reduced)
)
ggplot(residual_data, aes(x = fitted, y = residual)) +
geom_point(alpha = 0.25) +
geom_hline(yintercept = 0, linetype = "dashed") +
labs(
title = "Residuals versus fitted values",
x = "Fitted popularity",
y = "Residual"
) +
theme_minimal()
combined_predictions <- c(
fitted(linear_reduced),
pred_linear_reduced
)
prediction_min <- min(combined_predictions)
prediction_max <- max(combined_predictions)
below_zero <- sum(combined_predictions < 0)
above_100 <- sum(combined_predictions > 100)
prediction_min
## [1] -0.1800283
prediction_max
## [1] 32.92345
below_zero
## [1] 1
above_100
## [1] 0
Answer (a): Combined prediction range -0.18 to 32.92. Below 0: 1. Above 100: 0. Spread: fans out. The residual spread becomes wider as fitted popularity increases, suggesting that prediction errors are less consistent for songs with higher predicted popularity. The model also underpredicts some highly popular songs, as shown by the large positive residuals.
(b) (1 mark) In two sentences, state whether the line is equally trustworthy for ordinary songs and songs that already sound like hits, and what you would not promise a manager.
Answer (b): The model is more trustworthy for ordinary songs because the residuals are relatively smaller and more concentrated in that range. I would not promise a manager that the model can reliably predict hit songs or extreme popularity values.
Use the model you chose in Q8 (without loudness).
confint() gives 95% intervals.
(a) (3 marks) Use confint() to report
the 95% confidence intervals for energy and
danceability. Does each interval exclude 0?
Yes / no.
Write your code in the q10a chunk, then record your
answer below.
ci_reduced <- confint(
linear_reduced,
c("energy", "danceability"),
level = 0.95
)
energy_ci <- ci_reduced["energy", ]
danceability_ci <- ci_reduced["danceability", ]
energy_excludes_zero <-
energy_ci[1] > 0 | energy_ci[2] < 0
dance_excludes_zero <-
danceability_ci[1] > 0 | danceability_ci[2] < 0
energy_ci
## 2.5 % 97.5 %
## 0.4681774 3.3422537
danceability_ci
## 2.5 % 97.5 %
## 6.838207 10.233391
Answer (a): Energy interval 0.468 to 3.342. Excludes 0: yes. Danceability interval 6.838 to 10.233. Excludes 0: yes.
(b) (4 marks) In a few sentences, interpret the
energy coefficient in popularity points. State what is held
constant, include a practically meaningful change in energy (for
example, a 0.1 increase), and include the 95% confidence interval from
(a).
Write your code in the q10b chunk, then record your
answer below.
energy_effect_01 <- 0.1 * energy_reduced
energy_p <- summary(linear_reduced)$coefficients[
"energy",
"Pr(>|t|)"
]
energy_effect_01
## energy
## 0.1905216
energy_p
## [1] 0.009366698
Answer (b): Holding the other predictors fixed, a one-unit increase in energy is associated with 1.905 popularity points on average (95% interval 0.468 to 3.342). A 0.1 increase corresponds to about 0.191 popularity points. This is an association. There is no evidence that increasing energy causes an increase in popularity.
(c) (3 marks) In two or three sentences, choose one feature with a small p-value. Report its coefficient and p-value. Explain whether its effect is large enough to act on, or merely statistically detectable because the dataset is large.
Answer (c): For energy, coefficient = 1.905 and p = 0.009. The effect is not large enough to act on because a realistic 0.1 increase in energy is associated with only about 0.191 popularity points on a 0 to 100 scale.
Write a business brief for your manager.
Write here (8 marks; about 150 words): Our business problem is to predict song popularity on a 0 to 100 scale. The no model baseline predicts the training mean of 13.96 and has a test set RMSE of 15.32. The reduced linear model has a test set RMSE of 14.61, an improvement of 0.71 popularity points. Holding the other predictors fixed, a one unit increase in energy is associated with 1.905 popularity points, with a 95% interval from 0.468 to 3.342. A realistic 0.1 increase in energy corresponds to only 0.191 popularity points, so changing one audio feature is likely to make only a minor practical difference. The model can predict values outside the 0 to 100 range and is less reliable for extreme songs. I recommend using it for rough everyday forecasting, but not for predicting hits.
Use the same training set and test set. The outcome
is now is_top_song. For the logistic model, use the
original Q7 predictor list (including
loudness), not the reduced Q8 linear model. The model gives
P(top song). You choose the cutoff that turns that probability into yes
or no.
About 10% of songs are top songs, so “always say no” is already about 90% accurate. However, it finds nothing.
(a) (2 marks) Report the proportion of top songs in the training and test sets and the test-set accuracy obtained by always predicting “not a top song”. Briefly explain why this accuracy appears impressive but is unhelpful.
Write your code in the q12a chunk, then record your
answer below.
train_top_share <- mean(train$is_top_song)
test_top_share <- mean(test$is_top_song)
always_no_accuracy <- mean(!test$is_top_song)
train_top_share
## [1] 0.1053083
test_top_share
## [1] 0.1054824
always_no_accuracy
## [1] 0.8945176
Answer (a): Training top song share 10.53%; test top song share 10.55%. Always predicting “not top” gives test accuracy 89.45%, which looks impressive because most songs are not top songs but is unhelpful because it identifies no actual top songs.
(b) (4 marks) Fit a logistic regression to the
training set (family = binomial) using
is_top_song as the outcome and the same predictors as Q7
(including loudness). Print a brief summary. On the test
set, classify a song as top when its predicted probability is at least
0.5. Report the four confusion-matrix counts (TN, FP, FN, TP), accuracy,
sensitivity, and specificity. Here, sensitivity is the
proportion of actual top songs correctly identified, and
specificity is the proportion of actual non-top songs
correctly identified. Identify the false-negative cell (a missed top
song) and the false-positive cell (a false yes).
Write your code in the q12b chunk, then record your
answer below.
logit_full <- glm(
is_top_song ~ danceability + energy + valence + acousticness +
speechiness + instrumentalness + loudness + tempo +
explicit + main_genre + release_year,
data = train,
family = binomial
)
summary(logit_full)
##
## Call:
## glm(formula = is_top_song ~ danceability + energy + valence +
## acousticness + speechiness + instrumentalness + loudness +
## tempo + explicit + main_genre + release_year, family = binomial,
## data = train)
##
## Coefficients:
## Estimate Std. Error z value Pr(>|z|)
## (Intercept) -1.591e+02 1.876e+01 -8.483 < 2e-16 ***
## danceability 7.934e-01 2.157e-01 3.679 0.000234 ***
## energy -1.246e+00 2.422e-01 -5.145 2.68e-07 ***
## valence -7.821e-01 1.429e-01 -5.472 4.45e-08 ***
## acousticness -1.067e-01 1.354e-01 -0.788 0.430847
## speechiness -6.932e-01 2.917e-01 -2.377 0.017470 *
## instrumentalness -5.999e-01 1.297e-01 -4.625 3.75e-06 ***
## loudness 1.251e-01 1.218e-02 10.270 < 2e-16 ***
## tempo -2.586e-04 1.025e-03 -0.252 0.800930
## explicitTRUE 5.809e-01 8.160e-02 7.118 1.09e-12 ***
## main_genredance -6.555e-01 2.060e-01 -3.182 0.001460 **
## main_genrehip-hop 2.250e-01 2.493e-01 0.903 0.366601
## main_genreindie 7.034e-02 2.597e-01 0.271 0.786474
## main_genrelatin -1.670e-01 2.081e-01 -0.803 0.422213
## main_genremetal -5.941e-01 2.980e-01 -1.994 0.046202 *
## main_genreother -7.270e-01 2.010e-01 -3.618 0.000297 ***
## main_genrepop -8.957e-02 2.090e-01 -0.428 0.668302
## main_genrer-n-b -4.934e-01 2.382e-01 -2.071 0.038317 *
## main_genrerock -3.174e-01 2.166e-01 -1.466 0.142762
## release_year 7.887e-02 9.285e-03 8.494 < 2e-16 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## (Dispersion parameter for binomial family taken to be 1)
##
## Null deviance: 9422.6 on 13996 degrees of freedom
## Residual deviance: 8599.6 on 13977 degrees of freedom
## AIC: 8639.6
##
## Number of Fisher Scoring iterations: 6
logit_prob_05 <- predict(
logit_full,
newdata = test,
type = "response"
)
logit_pred_05 <- logit_prob_05 >= 0.5
actual_top <- test$is_top_song
TN_05 <- sum(!actual_top & !logit_pred_05)
FP_05 <- sum(!actual_top & logit_pred_05)
FN_05 <- sum(actual_top & !logit_pred_05)
TP_05 <- sum(actual_top & logit_pred_05)
accuracy_05 <- (TN_05 + TP_05) / nrow(test)
sensitivity_05 <- TP_05 / (TP_05 + FN_05)
specificity_05 <- TN_05 / (TN_05 + FP_05)
table(
Actual = actual_top,
Predicted = logit_pred_05
)
## Predicted
## Actual FALSE TRUE
## FALSE 5368 0
## TRUE 632 1
TN_05
## [1] 5368
FP_05
## [1] 0
FN_05
## [1] 632
TP_05
## [1] 1
accuracy_05
## [1] 0.8946842
sensitivity_05
## [1] 0.001579779
specificity_05
## [1] 1
Answer (b): TN 5368. FP 0. FN 632. TP 1. Accuracy 89.47%. Sensitivity 0.16%. Specificity 100%. FN is a missed top song. FP is a false yes.
Accuracy can sit next to the Q12 baseline while sensitivity stays near zero. That is not a coding error. Decide which mistake is worse: missing a top song, or saying yes too often. Do not hunt through many cutoffs on the test set.
(a) (4 marks) Choose a probability cutoff other than 0.5, based on business reasoning and, if useful, training-set results. Do not select or tune the cutoff using test-set performance. Justify the cutoff in one sentence, then apply it once to the test set. Report the four confusion-matrix counts (TN, FP, FN, TP), accuracy, sensitivity, and specificity.
Write your code in the q13a chunk, then record your
answer below.
chosen_cutoff <- 0.30
logit_pred_new <- logit_prob_05 >= chosen_cutoff
TN_new <- sum(!actual_top & !logit_pred_new)
FP_new <- sum(!actual_top & logit_pred_new)
FN_new <- sum(actual_top & !logit_pred_new)
TP_new <- sum(actual_top & logit_pred_new)
accuracy_new <- (TN_new + TP_new) / nrow(test)
sensitivity_new <- TP_new / (TP_new + FN_new)
specificity_new <- TN_new / (TN_new + FP_new)
TN_new
## [1] 5297
FP_new
## [1] 71
FN_new
## [1] 593
TP_new
## [1] 40
accuracy_new
## [1] 0.8893518
sensitivity_new
## [1] 0.06319115
specificity_new
## [1] 0.9867735
Answer (a): Cutoff 0.3. Reason: I chose a lower cutoff because missing a genuine top song is more costly than generating some false positive recommendations, so the decision prioritises sensitivity. TN 5297. FP 71. FN 593. TP 40. Accuracy 88.94%. Sensitivity 6.32%. Specificity 98.68%.
(b) (2 marks) From the test set,
select the track with track_name “Que Malo” and
artist_name “Bad Bunny”. Report its modelled probability of
being a top song, compare that probability with your Q13(a) cutoff, and
state the resulting yes/no decision. In two sentences, explain why the
decision follows from your chosen cutoff; do not judge it against 0.5
unless 0.5 is your chosen cutoff.
Write your code in the q13b chunk, then record your
answer below.
que_malo <- test |>
filter(
track_name == "Que Malo",
artist_name == "Bad Bunny"
)
que_malo_prob <- predict(
logit_full,
newdata = que_malo,
type = "response"
)
que_malo_decision <- que_malo_prob >= chosen_cutoff
que_malo_prob
## 1
## 0.2796899
que_malo_decision
## 1
## FALSE
Answer (b): Song: Que Malo by Bad Bunny. P = 0.28. Cutoff 0.3. Decision: no.
(a) (2 marks) From the Q12 logistic model, report
the energy coefficient and its 95% confidence interval from
confint(). Does the interval exclude 0?
Yes / no.
Write your code in the q14a chunk, then record your
answer below.
# Write your R here.
Answer (a): Energy coefficient ______. 95% interval ______ to ______. Excludes 0: yes / no.
(b) (6 marks) Interpret the sign and magnitude of
the energy coefficient, holding the other variables fixed.
Explain that a logistic coefficient changes the log-odds, or convert it
to an odds ratio. Illustrate with a 0.1 increase in energy. Do not
describe the coefficient as a constant change in probability.
Write your code in the q14b chunk, then record your
answer below.
# Write your R here.
Answer (b): Holding other predictors fixed, the energy coefficient is ______, so a one-unit increase reduces the log-odds by ______ on average (odds ratio ______). A 0.1 increase in energy ______. This is not a constant probability change because ______.
(a) (4 marks) In two short paragraphs, explain which model you would use for “How popular is this song?” (0–100) and which you would use for “Is it a top song?” (yes/no). Explain why these questions require different answers.
Write (a) here: For the 0–100 question, I would use ______ because ______. For the yes/no top-song question, I would use ______ with cutoff ______ because ______. These answers differ because ______.
(b) (4 marks) In four or five sentences, explain the limitations of the file and identify one question about song popularity that this extract cannot answer. Include the sources below in a reference list.
Le Drogoff, G. (2026). Spotify Huge Track Analysis Dataset. Hugging Face. https://huggingface.co/datasets/GildasLeDrogoff/spotify-huge-track-analysis-dataset
Pandya, M. (2022). Spotify Tracks Dataset. https://www.kaggle.com/datasets/maharshipandya/-spotify-tracks-dataset
Spotify for Developers. (2024, 27 November). Introducing some changes to our Web API. https://developer.spotify.com/blog/2024-11-27-changes-to-the-web-api
Write 200–250 words in total across parts (a) and (b). The reference list does not count towards the word guide.
Write (b) here: The file is limited by ______. One question this extract cannot answer is ______.
References: Include all three sources listed above in your submitted answer.
Part B is capped at 20 marks. Q16–Q18 contain 19 assessed marks, plus an optional 1-mark extension bonus. Without the optional extension, the maximum available for Part B is 19/20. If every group member is in ETF2121, skip this part and go straight to Part C.
Fit trees on the same predictors and the same training set and test set. This is not a third business problem — only whether a more flexible method is worth abandoning the simpler model.
A regression tree can bend where a line cannot. It does not give you a Q10-style coefficient.
(a) (2 marks) Fit a regression tree for
popularity on the training set using the same predictors as
Q7 and rpart(..., method = "anova"). Use the default
rpart settings; do not tune the tree on the test set. Print
the tree or printcp().
Write your code in the q16a chunk, then record your
answer below.
# Write your R here.
Answer (a): Fitted with
method = "anova": yes / no. First split or tree structure:
______.
(b) (6 marks) Calculate the test-set RMSE and compare it with the Q7 no-model baseline and the Q8 linear model. In two or three sentences, assess whether any improvement on the 0–100 scale is worth losing the interpretability of the coefficients.
Write your code in the q16b chunk, then record your
answer below.
# Write your R here.
Answer (b): Tree test-set RMSE ______. Linear-model RMSE ______. Baseline RMSE ______. I would / would not keep the tree because ______.
(a) (2 marks) Fit a classification tree for
is_top_song on the training set using the same predictors
as Q12 and rpart(..., method = "class"). Keep the
default rpart settings; do not set
cp or minsplit. Print the tree,
printcp(), and the class predictions. Does the default tree
ever predict “yes”?
Write your code in the q17a chunk, then record your
answer below.
# Write your R here.
Answer (a): The default tree does / does not predict any top songs. Evidence: ______.
(b) (6 marks) On the test set, report the four confusion-matrix counts (TN, FP, FN, TP), accuracy, sensitivity, and specificity. If the tree gives probabilities, apply the same cutoff as Q13(a). Sensitivity is the share of actual top songs found; specificity is the share of actual non-top songs correctly rejected. Compare the tree with Q12/Q13 and, in two sentences, state which error you would accept — missed top songs or false yeses — and why.
Write your code in the q17b chunk, then record your
answer below.
# Write your R here.
Answer (b): TN ______. FP ______. FN ______. TP ______. Accuracy ______. Sensitivity ______. Specificity ______. Compared with Q13, ______. I would accept ______ because ______.
(a) (2 marks) For Problem 1 (0–100), which method is better for a manager to use? Justify your choice with test-set RMSE and interpretability.
Answer (a): I choose ______. Its test-set RMSE is ______ versus ______ for ______, and its managerial advantage is ______.
(b) (1 mark) For Problem 2 (yes/no), which method is better for a manager to use? Justify your choice with the error you are willing to accept.
Answer (b): I choose ______ because ______.
Name one method not taught in this unit (for example, random forest or lasso). In about 80–120 words, explain what it could add to Problem 1 or Problem 2 and whether you would use it.
# Optional.
Answer: Method ______. It could add ______ to Problem 1 / Problem 2. I would / would not use it because ______.
10 marks for both ETF2121 and ETF5912.
Submit one recording per group, no longer than 5 minutes, due at the same time as the knit (23 October 2026, 11.55pm). Think of this as a briefing, not a live tutorial presentation.
Speak as if a Spotify manager has given you five minutes. Your recording will be assessed using these three criteria:
.mp4 or .mov, or provide a working
link through Monash OneDrive, Google Drive, or unlisted YouTube. The
marker must be able to open a shared link without requesting
access.A missing, locked, or unplayable recording scores 0 for Part C.
Required recording link or uploaded file name: ________________________________________________
The assignment files, recording, and FeedbackFruits group member evaluation are due Friday 23 October 2026, 11.55pm. Deadlines leave time for marking and feedback, and there is no extension on this group assignment under Monash policy. Everyone listed on the assignment should contribute; FeedbackFruits results will be used to assess each member’s contribution and may change that student’s individual mark.
group-assignment-template.Rmd.
The submitted folder should knit on a clean R session (template plus
data/ present).