This analysis predicts classe, the
manner in which a participant performed a dumbbell biceps curl. The five
outcome classes are A (correct form) and B–E (four common errors). I use
a random-forest classifier implemented by ranger, a fast
C++ implementation suitable for this dataset.
The workflow downloads the official training and 20-row quiz
datasets, removes identifiers and unusable sensor columns, estimates
performance with five-fold cross-validation on a stratified development
sample, checks the selected model on a separate holdout set, and then
refits the chosen configuration on all labelled observations. To reduce
knitting time, model tuning uses 150 trees per fit and three candidate
mtry values; the final model uses 300 trees. The final quiz
predictions are written to quiz_predictions.csv and
quiz_predictions.txt when this document is knitted.
The training data contain 19,622 labelled observations and the quiz data contain 20 observations. Measurements were collected from accelerometers worn at the belt, forearm, arm, and dumbbell. The original dataset describes five ways of performing the exercise, ranging from correct form to common mistakes.
if (!dir.exists("data")) dir.create("data", recursive = TRUE)
train_urls <- c(
"https://d396qusza40orc.cloudfront.net/predmachlearn/pml-training.csv",
"https://raw.githubusercontent.com/Badewannenente/PML-Prediction-Assignment/master/pml-training.csv"
)
test_urls <- c(
"https://d396qusza40orc.cloudfront.net/predmachlearn/pml-testing.csv",
"https://raw.githubusercontent.com/Badewannenente/PML-Prediction-Assignment/master/pml-testing.csv"
)
download_first_working <- function(urls, destination) {
if (file.exists(destination) && file.info(destination)$size > 1000) return(invisible(destination))
for (url in urls) {
ok <- tryCatch({
utils::download.file(url, destination, mode = "wb", quiet = TRUE)
file.exists(destination) && file.info(destination)$size > 1000
}, error = function(e) FALSE, warning = function(w) {
file.exists(destination) && file.info(destination)$size > 1000
})
if (isTRUE(ok)) return(invisible(destination))
if (file.exists(destination)) unlink(destination)
}
stop("Could not download the dataset. Check your internet connection and try again.")
}
train_file <- file.path("data", "pml-training.csv")
test_file <- file.path("data", "pml-testing.csv")
download_first_working(train_urls, train_file)
download_first_working(test_urls, test_file)
training_raw <- read.csv(train_file, na.strings = c("", "NA", "#DIV/0!"), check.names = TRUE)
testing_raw <- read.csv(test_file, na.strings = c("", "NA", "#DIV/0!"), check.names = TRUE)
knitr::kable(
data.frame(Dataset = c("Training", "Quiz test"), Rows = c(nrow(training_raw), nrow(testing_raw)), Columns = c(ncol(training_raw), ncol(testing_raw))),
caption = "Raw dataset dimensions"
)
| Dataset | Rows | Columns |
|---|---|---|
| Training | 19622 | 160 |
| Quiz test | 20 | 160 |
The first columns identify rows, participants, timestamps, and windows rather than the movement itself. They are excluded to reduce leakage from identifiers and recording order. I retain numeric sensor predictors that are available in both datasets, contain no missing or infinite values, and are not constant. The same predictor list and order are applied to training and quiz data.
metadata <- c(
"X", "user_name", "raw_timestamp_part_1", "raw_timestamp_part_2",
"cvtd_timestamp", "new_window", "num_window", "problem_id", "classe"
)
common_names <- intersect(names(training_raw), names(testing_raw))
predictor_names <- setdiff(common_names, metadata)
predictor_names <- predictor_names[vapply(training_raw[predictor_names], is.numeric, logical(1))]
predictor_names <- predictor_names[vapply(training_raw[predictor_names], function(x) all(is.finite(x)), logical(1))]
predictor_names <- predictor_names[vapply(testing_raw[predictor_names], function(x) all(is.finite(x)), logical(1))]
if (!length(predictor_names)) stop("No complete numeric predictors were found. Check the source files.")
nzv <- caret::nearZeroVar(training_raw[, predictor_names, drop = FALSE])
if (length(nzv)) predictor_names <- predictor_names[-nzv]
training <- training_raw[, c(predictor_names, "classe"), drop = FALSE]
training$classe <- factor(training$classe, levels = c("A", "B", "C", "D", "E"))
testing <- testing_raw[, predictor_names, drop = FALSE]
knitr::kable(
data.frame(Item = c("Labelled rows", "Quiz rows", "Usable sensor predictors", "Outcome classes"),
Value = c(nrow(training), nrow(testing), length(predictor_names), "A, B, C, D, E")),
caption = "Data after preparation"
)
| Item | Value |
|---|---|
| Labelled rows | 19622 |
| Quiz rows | 20 |
| Usable sensor predictors | 52 |
| Outcome classes | A, B, C, D, E |
library(ggplot2)
class_counts <- as.data.frame(table(training$classe))
names(class_counts) <- c("Class", "Count")
ggplot(class_counts, aes(x = Class, y = Count)) +
geom_col(fill = "#287D8E", width = 0.72) +
labs(title = "Training examples by class", x = "Exercise class", y = "Observations") +
theme_minimal(base_size = 12)
Distribution of the five exercise-quality classes in the labelled training data.
I use a random forest implemented with
caret and ranger. The dataset is divided once
into a stratified 70% development sample and a 30% holdout sample.
Within the development sample, five-fold cross-validation compares three
values of mtry (the number of candidate predictors
considered at each split). Each tuning fit uses 150 trees. The setting
with the highest cross-validated accuracy is selected. Using
ranger keeps this repeated fitting much faster than the
basic randomForest implementation.
The holdout data are not used to select mtry; they
provide a separate estimate of performance on unseen rows. After
evaluating the model, I refit a random forest with the selected
mtry on all 19,622 labelled rows and use it to predict the
20 quiz examples.
required <- c("caret", "ranger", "ggplot2")
missing <- required[!vapply(required, requireNamespace, logical(1), quietly = TRUE)]
if (length(missing)) stop("Install these packages before knitting: ", paste(missing, collapse = ", "))
set.seed(2026)
train_index <- caret::createDataPartition(training$classe, p = 0.70, list = FALSE)
development <- training[train_index, , drop = FALSE]
holdout <- training[-train_index, , drop = FALSE]
# Compare a small, explicit grid to avoid the long fit time caused by a larger search.
p <- ncol(development) - 1L
mtry_values <- sort(unique(pmax(1L, pmin(p, c(floor(sqrt(p)), floor(p / 3), floor(p / 2))))))
tune_grid <- data.frame(
mtry = mtry_values,
splitrule = rep("gini", length(mtry_values)),
min.node.size = rep(1L, length(mtry_values))
)
cv_control <- caret::trainControl(method = "cv", number = 5, savePredictions = "final")
set.seed(2026)
rf_cv <- caret::train(
classe ~ ., data = development,
method = "ranger", metric = "Accuracy",
trControl = cv_control, tuneGrid = tune_grid,
num.trees = 150, importance = "impurity", num.threads = 2
)
best_mtry <- rf_cv$bestTune$mtry
cv_row <- rf_cv$results[rf_cv$results$mtry == best_mtry, , drop = FALSE]
cv_accuracy <- cv_row$Accuracy[1]
cv_error <- 1 - cv_accuracy
holdout_prediction <- predict(rf_cv, newdata = holdout[, predictor_names, drop = FALSE])
holdout_cm <- caret::confusionMatrix(holdout_prediction, holdout$classe)
holdout_accuracy <- unname(holdout_cm$overall[["Accuracy"]])
holdout_error <- 1 - holdout_accuracy
metrics <- data.frame(
Metric = c("Best mtry", "Trees per cross-validation fit", "Five-fold CV accuracy", "Five-fold CV error estimate", "Holdout accuracy", "Holdout error"),
Result = c(as.character(best_mtry), "150", sprintf("%.3f%%", 100 * cv_accuracy), sprintf("%.3f%%", 100 * cv_error), sprintf("%.3f%%", 100 * holdout_accuracy), sprintf("%.3f%%", 100 * holdout_error))
)
knitr::kable(metrics, caption = "Model selection and generalisation estimates")
| Metric | Result |
|---|---|
| Best mtry | 7 |
| Trees per cross-validation fit | 150 |
| Five-fold CV accuracy | 99.207% |
| Five-fold CV error estimate | 0.793% |
| Holdout accuracy | 99.473% |
| Holdout error | 0.527% |
I define estimated out-of-sample error as 1 − accuracy. The five-fold cross-validation error estimates performance across repeated held-out folds in the development sample; the independent holdout error is an additional check. Neither figure guarantees the exact error on future data. Random row splitting can place measurements from the same participant in both partitions, so performance for a completely new participant may be lower.
cm_df <- as.data.frame(holdout_cm$table)
ggplot(cm_df, aes(x = Reference, y = Prediction, fill = Freq)) +
geom_tile(color = "white", linewidth = 0.6) +
geom_text(aes(label = Freq), size = 3.5) +
scale_fill_gradient(low = "#E7F3F4", high = "#145A6A") +
labs(title = "Holdout-set confusion matrix", x = "Actual class", y = "Predicted class", fill = "Count") +
theme_minimal(base_size = 12)
Holdout confusion matrix. Correct predictions appear on the diagonal; off-diagonal counts are classification errors.
The final forest is trained on the full labelled dataset using the
selected mtry. Its predictions for the 20 official quiz
rows are exported below. The quiz dataset does not contain the answer
labels, so these predictions cannot be evaluated locally.
set.seed(2026)
final_model <- ranger::ranger(
dependent.variable.name = "classe",
data = training,
num.trees = 300,
mtry = best_mtry,
splitrule = "gini",
min.node.size = 1,
importance = "impurity",
num.threads = 2,
seed = 2026
)
final_predictions <- predict(final_model, data = testing)$predictions
prediction_table <- data.frame(
problem_id = testing_raw$problem_id,
classe = as.character(final_predictions)
)
utils::write.csv(prediction_table, "quiz_predictions.csv", row.names = FALSE)
writeLines(as.character(final_predictions), "quiz_predictions.txt")
knitr::kable(prediction_table, caption = "Predictions for the 20 unlabeled quiz cases")
| problem_id | classe |
|---|---|
| 1 | B |
| 2 | A |
| 3 | B |
| 4 | A |
| 5 | A |
| 6 | E |
| 7 | D |
| 8 | B |
| 9 | A |
| 10 | A |
| 11 | B |
| 12 | C |
| 13 | B |
| 14 | A |
| 15 | E |
| 16 | E |
| 17 | A |
| 18 | B |
| 19 | B |
| 20 | B |
importance_values <- ranger::importance(final_model)
importance_df <- data.frame(Feature = names(importance_values), Importance = as.numeric(importance_values), row.names = NULL)
importance_df <- importance_df[order(importance_df$Importance, decreasing = TRUE), , drop = FALSE]
importance_top <- head(importance_df, 10)
ggplot(importance_top, aes(x = reorder(Feature, Importance), y = Importance)) +
geom_col(fill = "#D07A43") + coord_flip() +
labs(title = "Most informative sensor variables", x = NULL, y = "Mean Decrease in Gini") +
theme_minimal(base_size = 11)
Ten sensor variables with the greatest impurity importance in the final random forest.
The selected random forest uses the measured sensor variables to distinguish the five exercise classes. Cross-validation guides the model configuration, and the holdout set provides a separate estimate of prediction error. The final model is refitted on all available labelled data before generating the 20 quiz predictions.
There are two important limitations. First, the official quiz set contains only 20 rows, so it is too small to estimate a reliable error rate by itself. Second, random cross-validation does not fully test whether the model generalises to entirely new people; a participant-wise validation design would be a valuable follow-up if the goal were deployment beyond this dataset.
Velloso, E., Bulling, A., Gellersen, H., Ugulino, W., and Fuks, H. (2013). Qualitative Activity Recognition of Weight Lifting Exercises. Proceedings of the 4th International Conference in Cooperation with SIGCHI (Augmented Human ’13). Dataset: UCI Machine Learning Repository, Weight Lifting Exercises monitored with Inertial Measurement Units. The course data files are available from pml-training.csv and pml-testing.csv.