Executive summary

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.

Data and preparation

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"
)
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"
)
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.

Distribution of the five exercise-quality classes in the labelled training data.

Model and validation strategy

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")
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.

Holdout confusion matrix. Correct predictions appear on the diagonal; off-diagonal counts are classification errors.

Final quiz predictions

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")
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.

Ten sensor variables with the greatest impurity importance in the final random forest.

Conclusion and limitations

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.

Data and citation

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.