############################################################################### # AIR QUALITY DATA ANALYSIS IN R # # Advanced Statistical Programming (ACCA7022) # # # # Student : Daniel Hriscu # # Student ID: 2127929 # # Date : March 2026 # # # # Dataset: AirQualityUCI (UCI Machine Learning Repository) # # Source : https://archive.ics.uci.edu/dataset/360/air+quality # # Description: Hourly air pollutant and meteorological measurements from # # a multi-sensor device in an Italian city (Mar 2004–Feb 2005) # # # # Variables: # # Ground-truth (GT): CO, NMHC, C6H6, NOx, NO2 — reference analyser data # # Sensor responses: PT08.S1–S5 — metal-oxide sensor array readings # # Meteorological: T (temperature), RH (relative humidity), AH # # # # HOW TO RUN: # # RStudio : Press Ctrl+Shift+S (Windows) / Cmd+Shift+S (Mac) to Source # # the script. This gives clean output (results only, no code # # echo). Do NOT use Ctrl+Shift+Enter / Cmd+Shift+Return as # # that echoes every line of code alongside the results. # # R Console: source("AirQualityAnalysis.r", chdir = TRUE) # # # # PLOTS: # # All 11 plots are saved to the '2127929-plots' folder (created next to # # this script). When running in RStudio each plot is also displayed in # # the Plots pane so it can be reviewed interactively. File names include # # the task number and plot number for easy reference. # ############################################################################### # Student ID — used to name the output plots folder so multiple students' # outputs never conflict with each other on a shared machine. student_id <- "2127929" # Dependency management: install packages only if not already available. # This ensures the script is self-contained and can be run on a fresh R # installation without requiring manual setup — a key reproducibility practice. # NOTE: If packages were just installed, restart R before sourcing to avoid # namespace version conflicts (e.g., vctrs already loaded at an old version). required_packages <- c("ggplot2", "corrplot", "dplyr", "tidyr", "scales", "e1071") for (pkg in required_packages) { if (!suppressPackageStartupMessages(require(pkg, character.only = TRUE, quietly = TRUE))) { install.packages(pkg, repos = "https://cloud.r-project.org") suppressPackageStartupMessages(library(pkg, character.only = TRUE)) } } # Package purposes: # ggplot2 — Grammar of graphics for high-quality visualisations # corrplot — Specialised correlation matrix visualisation # dplyr — Data manipulation with clear, readable syntax # tidyr — Reshaping data for multi-variable plots # scales — Axis formatting helpers for ggplot2 # e1071 — Standard third-moment skewness (used in Task 5) # Helper function for clean, consistent section headers in console output. # Using a dedicated function avoids duplicating formatting logic throughout. section <- function(title) { width <- 70 line <- paste(rep("-", width), collapse = "") cat("\n", line, "\n ", title, "\n", line, "\n", sep = "") } # Resolve the directory containing this script so all output paths are relative # to the script's location — not the current working directory. This matters # because RStudio's Cmd+Shift+S runs source() WITHOUT chdir = TRUE, meaning the # working directory may point to ~/Documents or another folder entirely. Using # dirname(sys.frame(1)$ofile) captures the actual script path at runtime. # The tryCatch fallback handles interactive line-by-line execution gracefully. script_dir <- tryCatch( normalizePath(dirname(sys.frame(1)$ofile)), error = function(e) getwd() ) # Create the student-specific plots folder next to this script. # Using the student ID as a prefix prevents conflicts when multiple students' # outputs are stored on the same machine or submitted to the same directory. plot_dir <- file.path(script_dir, paste0(student_id, "-plots")) if (!dir.exists(plot_dir)) dir.create(plot_dir, recursive = TRUE) # Detect whether we are running inside RStudio. If so, we can display plots # directly in the Plots pane in addition to saving them to disk. In a plain # R console (macOS or Windows) there is no persistent plot viewer, so we # only save to files. in_rstudio <- Sys.getenv("RSTUDIO") == "1" # Helper: save a ggplot object to PNG and optionally display it in RStudio. # The optional 'label' parameter (e.g. "Task 2 | Plot 1 of 11") is embedded # as a caption in the bottom-left corner of the image — this makes plots # self-documenting and easier to track when reviewing a folder of files. # suppressWarnings() silences "Removed N rows containing non-finite values" # messages — these are just ggplot dropping NA rows, which is expected and # already handled by our na.rm = TRUE logic throughout. save_plot <- function(p, filename, label = NULL, w = 10, h = 6) { if (!is.null(label)) { p <- p + labs(caption = label) + theme(plot.caption = element_text( size = 9, colour = "#555555", hjust = 0, face = "italic", margin = margin(t = 6) )) } suppressWarnings( ggsave(file.path(plot_dir, filename), plot = p, width = w, height = h, dpi = 150, bg = "white") ) if (in_rstudio) { suppressWarnings(print(p)) Sys.sleep(0.3) } } # Helper: save a base R plot (corrplot, pairs, hist) to PNG and optionally # display it in RStudio. Accepts an expression that draws the plot. # The optional 'label' parameter adds a caption in the bottom-left margin, # matching the labelling convention used in save_plot() above. save_base_plot <- function(expr, filename, label = NULL, w = 800, h = 600) { # do_plot() encapsulates the full drawing sequence so it can be called # identically for both the PNG file and the RStudio Plots pane. do_plot <- function() { # Reserve 2 lines of outer bottom margin BEFORE drawing — corrplot/pairs # only reset par(mar), not par(oma), so this persists through the expression. old_par <- par(oma = c(3, 0, 0, 0)) on.exit(par(old_par), add = TRUE) eval(expr) if (!is.null(label)) { # line = 1.5 places text inside the 3-line outer margin we allocated, # safely below any axis labels or tick marks. mtext(label, side = 1, line = 1.5, cex = 0.65, col = "#555555", adj = 0, outer = TRUE, font = 3) } } png(file.path(plot_dir, filename), width = w, height = h, res = 150) do_plot() dev.off() if (in_rstudio) { do_plot() Sys.sleep(0.3) } } ############################################################################### ### TASK 1 — DATA IMPORT AND SUMMARY ### ############################################################################### section("TASK 1: DATA IMPORT AND SUMMARY") ### 1.1 Import the dataset --------------------------------------------------- # We download the dataset directly from the UCI Machine Learning Repository # so the script is fully self-contained and reproducible — no need to have # the CSV pre-downloaded in a specific folder. The archive contains a .zip # with AirQualityUCI.csv inside it. # # The CSV uses semicolons as field separators and commas as decimal marks # (European format common in Italian datasets). We specify both to ensure # numeric columns parse correctly rather than being read as character strings. dataset_url <- "https://archive.ics.uci.edu/static/public/360/air+quality.zip" zip_file <- file.path(script_dir, "air_quality.zip") csv_name <- "AirQualityUCI.csv" # filename as stored inside the zip csv_file <- file.path(script_dir, csv_name) # full path for reading # Download and extract only if the CSV isn't already present. # IMPORTANT: unzip(files = ...) must receive the bare filename that exists # inside the archive — NOT the full local path. exdir= controls where the # extracted file lands on disk. if (!file.exists(csv_file)) { cat(" Downloading dataset from UCI repository...\n") download.file(dataset_url, zip_file, mode = "wb", quiet = TRUE) unzip(zip_file, files = csv_name, exdir = script_dir, overwrite = TRUE) file.remove(zip_file) # Clean up the zip cat(" Download complete.\n") } else { cat(" Dataset already present locally.\n") } AirQuality <- read.csv( csv_file, sep = ";", dec = ",", header = TRUE, stringsAsFactors = FALSE ) ### 1.2 Initial inspection of column names and data types -------------------- # NOTE ON COLUMN NAMING: R's read.csv() applies make.names() to headers, # which replaces special characters (parentheses, spaces) with dots. # The original dataset header "CO(GT)" becomes "CO.GT." in R, and # "PT08.S1(CO)" becomes "PT08.S1.CO.", etc. This is standard R behaviour # and does not alter the data — only the programmatic column references. # # Original name → R name Description # Date → Date Measurement date (DD/MM/YYYY) # Time → Time Measurement time (HH.MM.SS) # CO(GT) → CO.GT. CO concentration (mg/m3) — reference # PT08.S1(CO) → PT08.S1.CO. Tin oxide sensor response (CO) # NMHC(GT) → NMHC.GT. Non-methane hydrocarbons (ug/m3) # C6H6(GT) → C6H6.GT. Benzene concentration (ug/m3) # PT08.S2(NMHC) → PT08.S2.NMHC. Titania sensor response (NMHC) # NOx(GT) → NOx.GT. NOx concentration (ppb) — reference # PT08.S3(NOx) → PT08.S3.NOx. Tungsten oxide sensor (NOx) # NO2(GT) → NO2.GT. NO2 concentration (ug/m3) — reference # PT08.S4(NO2) → PT08.S4.NO2. Tungsten oxide sensor (NO2) # PT08.S5(O3) → PT08.S5.O3. Indium oxide sensor (O3) # T → T Temperature (C) # RH → RH Relative humidity (%) # AH → AH Absolute humidity cat("\n Column names (as read by R — parentheses converted to dots):\n") cat(" ", paste(names(AirQuality), collapse = ", "), "\n") cat("\n Data types:\n") str(AirQuality, indent.str = " ") # Brief preview of the first few rows to confirm import cat("\n First 6 rows:\n") print(head(AirQuality)) ### 1.3 Remove trailing empty columns ---------------------------------------- # The CSV file (AirQualityUCI.csv) has trailing semicolons at the end of # every row (e.g., "10/03/2004;18.00.00;2,6;...;0,7578;;"). These trailing # ";;" cause read.csv() to create two extra columns filled entirely with NA, # which R names "X" and "X.1". These do NOT exist in the Excel version of # the dataset (AirQualityUCI.xlsx) — they are purely a CSV formatting artefact. # We detect and remove any all-NA columns programmatically. empty_cols <- sapply(AirQuality, function(col) all(is.na(col) | col == "")) cat("\n CSV artefact columns removed:", paste(names(AirQuality)[empty_cols], collapse = ", "), "(trailing semicolons in CSV)\n") AirQuality <- AirQuality[, !empty_cols] cat(" Remaining columns:", ncol(AirQuality), "\n") ### 1.4 Handle missing values ------------------------------------------------- # The dataset documentation states that -200 represents missing values. # We replace -200 with NA in all numeric columns so R's statistical functions # handle them correctly (e.g., na.rm = TRUE). numeric_cols <- sapply(AirQuality, is.numeric) AirQuality[numeric_cols] <- lapply(AirQuality[numeric_cols], function(col) { col[col == -200] <- NA return(col) }) # Calculate missing data counts and percentages na_counts <- colSums(is.na(AirQuality[numeric_cols])) na_pct <- round(na_counts / nrow(AirQuality) * 100, 1) cat("\n Missing data summary (after -200 -> NA replacement):\n\n") cat(sprintf(" %-16s %6s %8s\n", "Column", "NAs", "% Missing")) cat(" ", paste(rep("-", 34), collapse = ""), "\n", sep = "") for (col_name in names(na_counts)) { cat(sprintf(" %-16s %6d %7.1f%%\n", col_name, na_counts[col_name], na_pct[col_name])) } ### 1.5 Data quality observation: NMHC ---------------------------------------- # NMHC.GT. (non-methane hydrocarbons) has ~90% missing values (8557 of 9471). # This column is essentially unusable for reliable statistical analysis and # will be excluded from correlation/testing tasks. This is an important data # quality finding: the reference analyser for NMHC appears to have been # offline for most of the monitoring period. cat("\n *** NMHC.GT. has", na_pct["NMHC.GT."], "% missing — excluded from all analysis ***\n") ### 1.6 Parse Date and Time into a proper datetime column --------------------- # Combining Date and Time into a single POSIXct column enables time-series # plotting and extraction of temporal features (month, hour, season). AirQuality$DateTime <- as.POSIXct( paste(AirQuality$Date, AirQuality$Time), format = "%d/%m/%Y %H.%M.%S" ) # Extract useful temporal features for later analysis AirQuality$Month <- as.numeric(format(AirQuality$DateTime, "%m")) AirQuality$Hour <- as.numeric(format(AirQuality$DateTime, "%H")) # Assign meteorological seasons based on month # Winter: Dec–Feb, Spring: Mar–May, Summer: Jun–Aug, Autumn: Sep–Nov # Using standard Northern Hemisphere meteorological season definitions. AirQuality$Season <- factor( ifelse(AirQuality$Month %in% c(12, 1, 2), "Winter", ifelse(AirQuality$Month %in% c(3, 4, 5), "Spring", ifelse(AirQuality$Month %in% c(6, 7, 8), "Summer", "Autumn"))), levels = c("Winter", "Spring", "Summer", "Autumn") ) # Define time-of-day categories for Task 4 (traffic-based grouping) # Rush hours (7–9 AM, 17–19 PM) capture peak traffic emission periods. # Night hours (1–5 AM) represent minimal traffic baseline. AirQuality$TimePeriod <- ifelse( AirQuality$Hour %in% c(7, 8, 9, 17, 18, 19), "Rush Hour", ifelse(AirQuality$Hour %in% c(1, 2, 3, 4, 5), "Night", "Other") ) ### 1.7 Summary statistics ---------------------------------------------------- cat("\n Dimensions:", nrow(AirQuality), "rows x", ncol(AirQuality), "columns\n") cat(" Date range:", format(min(AirQuality$DateTime, na.rm = TRUE), "%d %b %Y"), "to", format(max(AirQuality$DateTime, na.rm = TRUE), "%d %b %Y"), "\n") cat(" Unparseable rows:", sum(is.na(AirQuality$DateTime)), "of", nrow(AirQuality), "\n") cat("\n Season distribution:\n") print(table(AirQuality$Season)) cat("\n Descriptive statistics (all numeric columns):\n") print(summary(AirQuality[, numeric_cols])) ############################################################################### ### TASK 2 — EXPLORATORY VISUALISATION ### ############################################################################### section("TASK 2: EXPLORATORY VISUALISATION") # We examine time trends in two key pollutants (CO and NO2) and explore # relationships between pollutants and meteorological variables. # Each plot type is chosen deliberately and justified in its comment block. ### 2.1 Time-Series Plot: CO concentration over time -------------------------- # A line plot is the natural choice for showing how a continuous variable # evolves over time. We use daily means to smooth out hourly noise while # preserving seasonal trends. CO (carbon monoxide) is a major urban pollutant # primarily from vehicle exhaust and domestic heating, so we expect clear # seasonal patterns (higher in winter due to inversions and heating). daily_means <- AirQuality %>% mutate(DateOnly = as.Date(DateTime)) %>% filter(!is.na(DateOnly)) %>% group_by(DateOnly) %>% summarise( CO_mean = mean(CO.GT., na.rm = TRUE), NO2_mean = mean(NO2.GT., na.rm = TRUE), Temp_mean = mean(T, na.rm = TRUE), .groups = "drop" ) cat("\n Plot 1: Daily mean CO time-series with LOESS smoother\n") p1 <- ggplot(daily_means, aes(x = DateOnly, y = CO_mean)) + geom_line(colour = "#2c7bb6", alpha = 0.7) + geom_smooth(method = "loess", formula = y ~ x, se = TRUE, colour = "#d7191c", linewidth = 1, span = 0.2) + labs( title = "Daily Mean CO Concentration Over Time", subtitle = "With LOESS smoother to highlight seasonal trend", x = "Date", y = "CO Concentration (mg/m\u00B3)" ) + scale_x_date(date_labels = "%b %Y", date_breaks = "2 months") + theme_minimal(base_size = 12) + theme(plot.title = element_text(face = "bold")) save_plot(p1, "Task2_Plot01_CO_timeseries.png", label = "Task 2 | Plot 1 of 11") ### 2.2 Time-Series Plot: NO2 concentration over time ------------------------- # NO2 (nitrogen dioxide) is chosen as the second pollutant because it has a # different emission profile from CO — it is influenced by both traffic and # atmospheric chemistry (photochemical reactions with O3). Comparing the two # time-series reveals whether both pollutants share the same seasonal pattern # or diverge due to their different atmospheric chemistries. cat(" Plot 2: Daily mean NO2 time-series with LOESS smoother\n") p2 <- ggplot(daily_means, aes(x = DateOnly, y = NO2_mean)) + geom_line(colour = "#fdae61", alpha = 0.7) + geom_smooth(method = "loess", formula = y ~ x, se = TRUE, colour = "#d7191c", linewidth = 1, span = 0.2) + labs( title = "Daily Mean NO2 Concentration Over Time", subtitle = "With LOESS smoother to highlight seasonal trend", x = "Date", y = "NO2 Concentration (\u00B5g/m\u00B3)" ) + scale_x_date(date_labels = "%b %Y", date_breaks = "2 months") + theme_minimal(base_size = 12) + theme(plot.title = element_text(face = "bold")) save_plot(p2, "Task2_Plot02_NO2_timeseries.png", label = "Task 2 | Plot 2 of 11") ### 2.3 Scatterplot: Temperature vs CO ---------------------------------------- # A scatterplot is ideal for exploring the bivariate relationship between a # pollutant and a meteorological variable. We hypothesise that higher # temperatures (summer) are associated with lower CO because of: # (a) increased atmospheric mixing (higher planetary boundary layer), and # (b) reduced domestic heating emissions. # Points are coloured by season to add a third dimension of information. # # NOTE: The overall linear trend appears nearly flat because it aggregates # across seasons — a classic instance of Simpson's paradox. The seasonal # colouring reveals the real pattern: winter clusters at low-temp/high-CO # and summer at high-temp/low-CO. Per-season regression lines (below) show # the within-season relationships more honestly than a single pooled line. cat(" Plot 3: Temperature vs CO scatterplot (coloured by season)\n") p3 <- ggplot(AirQuality %>% filter(!is.na(CO.GT.) & !is.na(T) & !is.na(Season)), aes(x = T, y = CO.GT., colour = Season)) + geom_point(alpha = 0.2, size = 0.8) + geom_smooth(method = "lm", formula = y ~ x, se = FALSE, linewidth = 0.9, linetype = "dashed") + scale_colour_manual(values = c( "Winter" = "#2c7bb6", "Spring" = "#abdda4", "Summer" = "#fdae61", "Autumn" = "#d7191c" )) + labs( title = "Relationship Between Temperature and CO Concentration", subtitle = "Per-season regression lines reveal within-group trends (Simpson's paradox)", x = "Temperature (\u00B0C)", y = "CO (mg/m\u00B3)" ) + theme_minimal(base_size = 12) + theme(plot.title = element_text(face = "bold")) save_plot(p3, "Task2_Plot03_Temp_vs_CO.png", label = "Task 2 | Plot 3 of 11") ### 2.4 Boxplot: NO2 levels by hour of day ------------------------------------ # Boxplots summarise the distribution (median, IQR, outliers) at each hour, # revealing the characteristic double-peak diurnal pattern of NO2 driven by # morning and evening rush-hour traffic. This is more informative than a # simple line of means because it shows the full spread and variability # at each hour. The two peaks (8–9 AM and 18–19 PM) correspond to commuting. cat(" Plot 4: NO2 diurnal pattern (boxplots by hour)\n") p4 <- ggplot(AirQuality %>% filter(!is.na(NO2.GT.) & !is.na(Hour)), aes(x = factor(Hour), y = NO2.GT.)) + geom_boxplot(fill = "#fdae61", colour = "#333333", outlier.alpha = 0.2, outlier.size = 0.5) + labs( title = "Diurnal Pattern of NO2 Concentration", subtitle = "Boxplots showing distribution at each hour of the day", x = "Hour of Day", y = "NO2 (\u00B5g/m\u00B3)" ) + theme_minimal(base_size = 12) + theme(plot.title = element_text(face = "bold")) save_plot(p4, "Task2_Plot04_NO2_diurnal.png", label = "Task 2 | Plot 4 of 11") ### 2.5 Heatmap: Average pollutant concentration by hour and month ------------- # A heatmap reveals the interaction between two temporal dimensions (hour and # month) simultaneously. This is a more advanced visualisation that exposes # patterns invisible in one-dimensional plots: e.g., rush-hour peaks may be # more pronounced in winter than summer due to atmospheric stability. cat(" Plot 5: CO heatmap (hour x month interaction)\n") hourly_monthly <- AirQuality %>% filter(!is.na(CO.GT.) & !is.na(Hour) & !is.na(Month)) %>% group_by(Month, Hour) %>% summarise(CO_mean = mean(CO.GT., na.rm = TRUE), .groups = "drop") p5 <- ggplot(hourly_monthly, aes(x = factor(Hour), y = factor(Month), fill = CO_mean)) + geom_tile() + scale_fill_gradient2( low = "#2c7bb6", mid = "#ffffbf", high = "#d7191c", midpoint = median(hourly_monthly$CO_mean), name = "CO (mg/m\u00B3)" ) + scale_y_discrete(labels = month.abb[c(3:12, 1:4)]) + labs( title = "CO Concentration Heatmap: Hour of Day vs Month", subtitle = "Darker red indicates higher concentrations", x = "Hour of Day", y = "Month" ) + theme_minimal(base_size = 12) + theme(plot.title = element_text(face = "bold")) save_plot(p5, "Task2_Plot05_CO_heatmap.png", label = "Task 2 | Plot 5 of 11") ### 2.6 Scatterplot: Relative Humidity vs NO2 --------------------------------- # The brief asks for relationships between pollutants and weather-related # variables (plural). Plot 3 covered Temperature vs CO; this second weather # relationship examines Relative Humidity vs NO2. Higher humidity often # coincides with low-level inversions and reduced vertical mixing, which # can trap NO2 near ground level. We add a LOESS smoother to reveal # any non-linear pattern. cat(" Plot 6: Relative Humidity vs NO2 scatterplot\n") p6 <- ggplot(AirQuality %>% filter(!is.na(NO2.GT.) & !is.na(RH)), aes(x = RH, y = NO2.GT.)) + geom_point(alpha = 0.15, size = 0.8, colour = "#2c7bb6") + geom_smooth(method = "loess", formula = y ~ x, se = TRUE, colour = "#d7191c", linewidth = 1, span = 0.3) + labs( title = "Relationship Between Relative Humidity and NO2 Concentration", subtitle = "LOESS smoother reveals non-linear association", x = "Relative Humidity (%)", y = "NO2 (\u00B5g/m\u00B3)" ) + theme_minimal(base_size = 12) + theme(plot.title = element_text(face = "bold")) save_plot(p6, "Task2_Plot06_RH_vs_NO2.png", label = "Task 2 | Plot 6 of 11") ############################################################################### ### TASK 3 — CORRELATION ANALYSIS ### ############################################################################### section("TASK 3: CORRELATION ANALYSIS") # We calculate correlations between four ground-truth pollutants: # CO, NOx, NO2, and C6H6 (benzene). NMHC is excluded due to ~90% missing # data (see Task 1.5). These four are chosen because they share common # emission sources (vehicular traffic, combustion) but have different # atmospheric lifetimes and reaction pathways, making their inter-correlations # scientifically meaningful. ### 3.1 Select pollutant columns and compute correlation matrices -------------- pollutants <- AirQuality[, c("CO.GT.", "NOx.GT.", "NO2.GT.", "C6H6.GT.")] names(pollutants) <- c("CO", "NOx", "NO2", "C6H6") # Pearson correlation: measures linear relationships. # Assumes bivariate normality, which may be violated with skewed pollutant data. cor_pearson <- cor(pollutants, use = "pairwise.complete.obs", method = "pearson") # Spearman correlation: measures monotonic relationships using ranks. # More robust to outliers and non-normality — important for skewed pollutant # data where extreme concentration events are common. cor_spearman <- cor(pollutants, use = "pairwise.complete.obs", method = "spearman") cat("\n Pearson correlation matrix:\n") print(round(cor_pearson, 3)) cat("\n Spearman correlation matrix:\n") print(round(cor_spearman, 3)) ### 3.2 Statistical significance of correlations ------------------------------- # We test whether each correlation is significantly different from zero. # With n > 7000 observations, even very small correlations can be significant, # so we focus on both p-values AND effect size (the r value itself). cat("\n Significance tests (Pearson):\n\n") cat(sprintf(" %-14s %8s %10s %10s %20s\n", "Pair", "r", "t", "p", "95% CI")) cat(" ", paste(rep("-", 66), collapse = ""), "\n", sep = "") pollutant_names <- names(pollutants) for (i in 1:(length(pollutant_names) - 1)) { for (j in (i + 1):length(pollutant_names)) { ct <- cor.test(pollutants[[i]], pollutants[[j]], method = "pearson") pair <- paste(pollutant_names[i], "vs", pollutant_names[j]) cat(sprintf(" %-14s %8.3f %10.2f %10s [%6.3f, %5.3f]\n", pair, ct$estimate, ct$statistic, format.pval(ct$p.value, digits = 3), ct$conf.int[1], ct$conf.int[2])) } } ### 3.3 Correlogram (heatmap) ------------------------------------------------- # A correlogram provides an immediate visual summary of pairwise correlations. # corrplot's "color" method with numeric labels gives both the colour-coded # strength and exact values, making interpretation straightforward. # We use hierarchical clustering to reorder variables so that highly correlated # pollutants appear adjacent. cat("\n Plot 7: Correlation heatmap\n") plot7_expr <- expression( corrplot( cor_pearson, method = "color", type = "upper", order = "hclust", addCoef.col = "black", tl.col = "black", tl.srt = 45, col = colorRampPalette(c("#2c7bb6", "white", "#d7191c"))(200), title = "Pearson Correlation Matrix: Ground-Truth Pollutants", mar = c(0, 0, 2, 0) ) ) save_base_plot(plot7_expr, "Task3_Plot07_correlation_heatmap.png", label = "Task 3 | Plot 7 of 11") ### 3.4 Scatterplot matrix for detailed pairwise inspection ------------------- # While the correlogram shows strength, scatterplots reveal the SHAPE of # relationships (linear vs curvilinear) and potential outliers. panels() # gives a compact multi-panel view. The lower panel uses a LOESS smoother # to show whether the relationship deviates from linearity. cat(" Plot 8: Scatterplot matrix with LOESS smoothers\n") plot8_expr <- expression( pairs( pollutants, pch = 16, col = adjustcolor("#2c7bb6", alpha.f = 0.1), main = "Scatterplot Matrix: Pairwise Pollutant Relationships", lower.panel = panel.smooth ) ) save_base_plot(plot8_expr, "Task3_Plot08_scatterplot_matrix.png", label = "Task 3 | Plot 8 of 11", w = 1200, h = 1200) ### 3.5 Interpretation --------------------------------------------------------- cat("\n Interpretation:\n") cat(" CO-C6H6: r = 0.93 — strongest; both from incomplete combustion.\n") cat(" NOx-NO2: r = 0.76 — NO2 is a chemical component of NOx.\n") cat(" Pearson ~ Spearman throughout, confirming linear (not just\n") cat(" monotonic) relationships. All p < 2.2e-16, but with n > 7000\n") cat(" that is expected — effect sizes (all r > 0.6) are what matter.\n") cat("\n Limitation: 'pairwise.complete.obs' means each pair uses only\n") cat(" rows where both variables are non-missing, so effective sample\n") cat(" sizes differ slightly across pairs. Given that most columns have\n") cat(" < 15% missing data (except NMHC, which was excluded), this has\n") cat(" negligible impact on the results.\n") ############################################################################### ### TASK 4 — HYPOTHESIS TESTING ### ############################################################################### section("TASK 4: HYPOTHESIS TEST — CO (RUSH HOUR vs NIGHT)") # Research question: Does CO concentration differ significantly between # rush-hour periods and nighttime? # # Conditions-based comparison: The brief suggests comparing a pollutant # "under two conditions, for example, high versus low wind speed." However, # this dataset lacks a direct wind-speed variable (only sensor proxies are # available), and a crude median split on a continuous meteorological variable # introduces confounders (season, time of day). Instead, we compare CO under # two well-defined traffic conditions — rush hours vs nighttime — which # provides a clearer causal mechanism and more actionable policy findings # (e.g., congestion charging, low-emission zones). # # Health context: The WHO 2021 guideline for CO is 4 mg/m³ (24-hour mean). # The EU Directive 2008/50/EC sets a maximum 8-hour mean of 10 mg/m³. # These thresholds provide context for interpreting our measured values. ### 4.1 Create traffic-based groups -------------------------------------------- # We use the TimePeriod variable created during data preparation. # Only "Rush Hour" and "Night" are compared (the "Other" category is excluded # to create a clean two-group comparison with a clear scientific contrast). test_data <- AirQuality %>% filter(TimePeriod %in% c("Rush Hour", "Night") & !is.na(CO.GT.)) test_data$TimePeriod <- factor(test_data$TimePeriod, levels = c("Night", "Rush Hour")) cat("\n Groups:\n") group_stats <- test_data %>% group_by(TimePeriod) %>% summarise( N = n(), Mean = round(mean(CO.GT., na.rm = TRUE), 2), Median = round(median(CO.GT., na.rm = TRUE), 2), SD = round(sd(CO.GT., na.rm = TRUE), 2), IQR = round(IQR(CO.GT., na.rm = TRUE), 2), .groups = "drop" ) print(as.data.frame(group_stats)) ### 4.2 Check assumptions for test selection ---------------------------------- # For a two-group comparison, we choose between: # - Independent samples t-test (parametric): assumes normality and equal variances # - Wilcoxon rank-sum test (non-parametric): no distributional assumptions # # We check normality with Shapiro-Wilk on subsamples (the full groups exceed # the 5000-observation limit). We also use histograms for visual inspection — # with large samples, formal tests reject normality for trivial deviations, # so visual assessment is important. cat("\n Normality check (Shapiro-Wilk):\n") set.seed(42) # Reproducibility night_sample <- sample(test_data$CO.GT.[test_data$TimePeriod == "Night"], size = min(5000, sum(test_data$TimePeriod == "Night"))) rush_sample <- sample(test_data$CO.GT.[test_data$TimePeriod == "Rush Hour"], size = min(5000, sum(test_data$TimePeriod == "Rush Hour"))) shapiro_night <- shapiro.test(night_sample) shapiro_rush <- shapiro.test(rush_sample) cat(" Night: W =", round(shapiro_night$statistic, 4), ", p =", format.pval(shapiro_night$p.value, digits = 3), "\n") cat(" Rush Hour: W =", round(shapiro_rush$statistic, 4), ", p =", format.pval(shapiro_rush$p.value, digits = 3), "\n") cat(" -> Both groups reject normality. Using non-parametric test.\n") # Visualise distributions — histograms with density overlay for shape assessment cat("\n Plot 9: CO distribution histograms (Night vs Rush Hour)\n") plot9_expr <- expression({ par(mfrow = c(1, 2)) hist(test_data$CO.GT.[test_data$TimePeriod == "Night"], breaks = 40, col = "#abd9e9", border = "white", probability = TRUE, main = "CO Distribution — Night (1-5 AM)", xlab = "CO (mg/m\u00B3)") lines(density(test_data$CO.GT.[test_data$TimePeriod == "Night"], na.rm = TRUE), col = "#d7191c", lwd = 2) hist(test_data$CO.GT.[test_data$TimePeriod == "Rush Hour"], breaks = 40, col = "#fdae61", border = "white", probability = TRUE, main = "CO Distribution — Rush Hour", xlab = "CO (mg/m\u00B3)") lines(density(test_data$CO.GT.[test_data$TimePeriod == "Rush Hour"], na.rm = TRUE), col = "#d7191c", lwd = 2) par(mfrow = c(1, 1)) }) save_base_plot(plot9_expr, "Task4_Plot09_CO_histograms.png", label = "Task 4 | Plot 9 of 11", w = 1500, h = 600) ### 4.3 Perform the hypothesis test ------------------------------------------- # Both distributions are right-skewed (positive skew typical of pollutant # concentrations) and the Shapiro-Wilk test rejects normality. Therefore, # we use the Wilcoxon rank-sum test (Mann-Whitney U), the non-parametric # alternative to the independent-samples t-test. # # Hypotheses: # H0: The distribution of CO concentrations is the same for night and # rush-hour periods (no difference in central tendency). # H1: The distribution of CO concentrations differs between night and # rush-hour periods (two-sided test). # Significance level: alpha = 0.05 cat("\n Hypotheses:\n") cat(" H0: The distribution of CO is the same for night and rush-hour periods.\n") cat(" H1: The distribution of CO differs between night and rush-hour periods.\n") cat(" Significance level: alpha = 0.05\n") wilcox_result <- wilcox.test( CO.GT. ~ TimePeriod, data = test_data, alternative = "two.sided", conf.int = TRUE # Request Hodges-Lehmann estimate of location shift ) cat("\n Wilcoxon rank-sum test:\n") cat(" W =", wilcox_result$statistic, "\n") cat(" p =", format.pval(wilcox_result$p.value, digits = 3), "\n") cat(" 95% CI for location shift: [", round(wilcox_result$conf.int[1], 2), ",", round(wilcox_result$conf.int[2], 2), "]\n") ### 4.4 Effect size: rank-biserial correlation --------------------------------- # Statistical significance alone is insufficient — with large samples, even # tiny differences can be significant. We calculate the rank-biserial # correlation (r_rb) as an effect size measure. This converts the Wilcoxon W # statistic into a value between -1 and 1, analogous to Cohen's d. # Interpretation: |r_rb| < 0.1 negligible, 0.1–0.3 small, 0.3–0.5 medium, > 0.5 large. n1 <- sum(test_data$TimePeriod == "Night") n2 <- sum(test_data$TimePeriod == "Rush Hour") r_rb <- 1 - (2 * wilcox_result$statistic) / (n1 * n2) median_night <- median(test_data$CO.GT.[test_data$TimePeriod == "Night"], na.rm = TRUE) median_rush <- median(test_data$CO.GT.[test_data$TimePeriod == "Rush Hour"], na.rm = TRUE) cat("\n Results summary:\n") cat(" Median CO (Night): ", median_night, "mg/m\u00B3\n") cat(" Median CO (Rush Hour): ", median_rush, "mg/m\u00B3\n") cat(" Difference: ", median_rush - median_night, "mg/m\u00B3\n") cat(" Hodges-Lehmann shift: ", round(wilcox_result$estimate, 3), "\n") cat(" Rank-biserial r: ", round(r_rb, 3), " ->", ifelse(abs(r_rb) > 0.5, "LARGE effect", ifelse(abs(r_rb) > 0.3, "MEDIUM effect", "SMALL effect")), "\n") ### 4.5 Supplementary: boxplot comparison -------------------------------------- # Visualise the two groups side by side to complement the statistical test. cat("\n Plot 10: CO boxplot comparison (Night vs Rush Hour)\n") p10 <- ggplot(test_data, aes(x = TimePeriod, y = CO.GT., fill = TimePeriod)) + geom_boxplot(width = 0.5, outlier.alpha = 0.3, colour = "#333333") + stat_summary(fun = mean, geom = "point", shape = 23, size = 3, fill = "white", colour = "black") + scale_fill_manual(values = c("Night" = "#abd9e9", "Rush Hour" = "#fdae61")) + labs( title = "CO Concentration: Night vs Rush Hour", subtitle = "Boxplots with mean (diamond) and median (line)", x = "Time Period", y = "CO (mg/m\u00B3)" ) + theme_minimal(base_size = 12) + theme(plot.title = element_text(face = "bold"), legend.position = "none") save_plot(p10, "Task4_Plot10_CO_boxplot_comparison.png", label = "Task 4 | Plot 10 of 11") ### 4.6 Interpretation --------------------------------------------------------- cat("\n Interpretation:\n") if (wilcox_result$p.value < 0.05) { cat(" Reject H0 (p < 0.05). CO differs significantly between\n") cat(" nighttime and rush-hour periods. Rush-hour CO (median", median_rush, "mg/m\u00B3)\n") cat(" is", round(median_rush / median_night, 1), "x higher than nighttime (", median_night, "mg/m\u00B3).\n") cat(" The large effect size (r =", round(abs(r_rb), 3), ") confirms\n") cat(" this is practically meaningful, not just statistically significant.\n") cat(" Traffic emissions are clearly the dominant urban CO driver.\n") } else { cat(" Fail to reject H0 (p >= 0.05). No significant difference.\n") } ############################################################################### ### TASK 5 — GROUP COMPARISON: POLLUTANT ACROSS SEASONS ### ############################################################################### section("TASK 5: GROUP COMPARISON — NO2 ACROSS SEASONS") # Research question: Do NO2 concentrations differ significantly across # the four meteorological seasons (Winter, Spring, Summer, Autumn)? # # Rationale: NO2 is selected because it has both primary (direct emission) # and secondary (photochemical) sources, making it particularly sensitive # to seasonal changes in sunlight, temperature, and traffic patterns. # Winter inversions trap pollutants, while summer photochemistry converts # NO to NO2 via reaction with ozone but also breaks NO2 down via photolysis. # We therefore expect seasonal variation with complex drivers. ### 5.1 Descriptive statistics by season -------------------------------------- season_data <- AirQuality %>% filter(!is.na(NO2.GT.) & !is.na(Season)) cat("\n Descriptive statistics by season:\n\n") season_summary <- season_data %>% group_by(Season) %>% summarise( N = n(), Mean = round(mean(NO2.GT., na.rm = TRUE), 2), Median = round(median(NO2.GT., na.rm = TRUE), 2), SD = round(sd(NO2.GT., na.rm = TRUE), 2), Min = min(NO2.GT., na.rm = TRUE), Max = max(NO2.GT., na.rm = TRUE), IQR = round(IQR(NO2.GT., na.rm = TRUE), 2), Skew = round(e1071::skewness(NO2.GT., na.rm = TRUE), 3), .groups = "drop" ) # Note: e1071::skewness() computes the standard third-moment skewness # (type 2 by default, matching SAS/SPSS). This is preferred over Pearson's # second coefficient of skewness ((mean - median) / SD) which is only an # approximation and not the standard measure used in statistical software. print(as.data.frame(season_summary)) ### 5.2 Check assumptions for test selection ---------------------------------- # For comparing more than two groups, we consider: # - One-way ANOVA (parametric): assumes normality within groups and # homogeneity of variance across groups # - Kruskal-Wallis (non-parametric): rank-based, no distributional assumptions # # We formally check both assumptions to justify our test choice. cat("\n Normality check (Shapiro-Wilk by season):\n") set.seed(42) for (s in levels(season_data$Season)) { vals <- season_data$NO2.GT.[season_data$Season == s] vals <- vals[!is.na(vals)] samp <- sample(vals, size = min(5000, length(vals))) sw <- shapiro.test(samp) cat(" ", sprintf("%-8s", s), ": W =", round(sw$statistic, 4), ", p =", format.pval(sw$p.value, digits = 3), "\n") } # Bartlett's test for homogeneity of variances # This is sensitive to non-normality, so we also note the SDs from the summary. bartlett_result <- bartlett.test(NO2.GT. ~ Season, data = season_data) cat("\n Variance homogeneity (Bartlett's test):\n") cat(" K-squared =", round(bartlett_result$statistic, 2), ", p =", format.pval(bartlett_result$p.value, digits = 3), "\n") cat(" SD ratio (max/min):", round(max(season_summary$SD) / min(season_summary$SD), 2), "\n") cat(" -> Both normality and equal-variance assumptions violated.\n") cat(" Using Kruskal-Wallis (non-parametric).\n") ### 5.3 Perform the Kruskal-Wallis test ---------------------------------------- # All four Shapiro-Wilk tests reject normality (p < 0.001) and Bartlett's # test rejects equal variances (p < 0.001). Both ANOVA assumptions are # violated, so the Kruskal-Wallis rank-sum test is the appropriate # non-parametric alternative. # # Hypotheses: # H0: The distribution of NO2 concentrations is the same across all # four seasons (no difference in central tendency). # H1: At least one season has a significantly different distribution # of NO2 concentrations. # Significance level: alpha = 0.05 cat("\n Hypotheses:\n") cat(" H0: NO2 concentrations are the same across all four seasons.\n") cat(" H1: At least one season has a different NO2 distribution.\n") cat(" Significance level: alpha = 0.05\n") kw_result <- kruskal.test(NO2.GT. ~ Season, data = season_data) # Effect size: epsilon-squared (analogous to eta-squared for ANOVA). # Formula: epsilon_sq = H / (n - 1), where H is the Kruskal-Wallis statistic. # Ranges from 0 to 1; values > 0.14 are considered large (Tomczak & Tomczak, 2014). n_total <- nrow(season_data) k_groups <- length(levels(season_data$Season)) # k = 4 epsilon_sq <- kw_result$statistic / (n_total - 1) cat("\n Kruskal-Wallis test:\n") cat(" Chi-squared =", round(kw_result$statistic, 2), ", df =", kw_result$parameter, ", p =", format.pval(kw_result$p.value, digits = 3), "\n") cat(" Epsilon-squared =", round(epsilon_sq, 4), "\n") ### 5.4 Post-hoc pairwise comparisons ----------------------------------------- # The Kruskal-Wallis test tells us THAT at least one group differs, but not # WHICH groups. We perform pairwise Wilcoxon rank-sum tests with Bonferroni # correction to control the family-wise error rate (6 pairwise comparisons). # Bonferroni is conservative (multiplies p-values by k), reducing Type I error # risk at the cost of slightly reduced power. if (kw_result$p.value < 0.05) { cat("\n Post-hoc pairwise Wilcoxon (Bonferroni correction):\n\n") posthoc <- pairwise.wilcox.test( season_data$NO2.GT., season_data$Season, p.adjust.method = "bonferroni" ) print(posthoc) } else { cat("\n Kruskal-Wallis not significant — no post-hoc tests needed.\n") } ### 5.5 Visualisation: Boxplots with violin overlay ---------------------------- # Boxplots are ideal for comparing distributions across groups. They show # median, IQR, and outliers, enabling direct visual comparison. # We add a violin layer underneath to show the full density shape — this # reveals whether distributions are unimodal/bimodal and where data # concentrates, which boxplots alone can hide. # Mean markers (diamonds) complement the median lines. cat("\n Plot 11: NO2 violin + boxplot by season\n") p11 <- ggplot(season_data, aes(x = Season, y = NO2.GT., fill = Season)) + geom_violin(alpha = 0.3, colour = NA) + geom_boxplot( width = 0.3, outlier.alpha = 0.2, outlier.size = 0.5, colour = "#333333" ) + stat_summary( fun = mean, geom = "point", shape = 23, size = 3, fill = "white", colour = "black" ) + scale_fill_manual(values = c( "Winter" = "#2c7bb6", "Spring" = "#abdda4", "Summer" = "#fdae61", "Autumn" = "#d7191c" )) + labs( title = "NO2 Concentration Across Seasons", subtitle = "Violin + boxplot with mean (diamond) and median (line)", x = "Season", y = "NO2 (\u00B5g/m\u00B3)" ) + theme_minimal(base_size = 12) + theme( plot.title = element_text(face = "bold"), legend.position = "none" ) save_plot(p11, "Task5_Plot11_NO2_violin_seasonal.png", label = "Task 5 | Plot 11 of 11") ### 5.6 Interpretation --------------------------------------------------------- # Generate interpretation from computed variables rather than hardcoding # values, ensuring correctness if the underlying data were to change. ordered_seasons <- season_summary[order(-season_summary$Median), ] highest <- ordered_seasons[1, ] lowest <- ordered_seasons[nrow(ordered_seasons), ] cat("\n Interpretation:\n") cat(" Seasonal ranking by median NO2 (ug/m3):\n") for (i in seq_len(nrow(ordered_seasons))) { s <- ordered_seasons[i, ] tag <- if (i == 1) " <- HIGHEST" else if (i == nrow(ordered_seasons)) " <- LOWEST" else "" cat(sprintf(" %s: median %g, mean %.1f, SD %.1f%s\n", s$Season, s$Median, s$Mean, s$SD, tag)) } cat("\n The Kruskal-Wallis test confirms at least one season differs\n") cat(" significantly (p < 0.001). Post-hoc pairwise Wilcoxon tests\n") cat(" reveal a three-tier structure: Winter is highest, Summer is lowest,\n") cat(" and Spring/Autumn form an intermediate group not significantly\n") cat(" different from each other.\n") cat("\n Scientific explanation:\n") cat(" Winter highs: inversions trap NO2; less UV photolysis;\n") cat(" heating emissions add to traffic baseline.\n") cat(" Summer lows: deeper boundary layer disperses pollutants;\n") cat(" strong UV drives NO2 photolysis (NO2 -> NO + O).\n") cat(" Spring/Autumn: transitional — similar temperatures, UV, mixing.\n") cat("\n Health context:\n") cat(" WHO 2021 annual guideline: 10 ug/m3. EU limit: 40 ug/m3.\n") cat(sprintf(" All seasonal medians (%g-%g) far exceed WHO guidelines —\n", lowest$Median, highest$Median)) cat(" even summer 'lows' are a significant public health concern.\n") ############################################################################### ### CODE QUALITY NOTES (Task 6) ### ############################################################################### # This script demonstrates the following code quality practices: # # STRUCTURE: # 1. Dependencies managed at the top with automated installation # 2. Each task in its own clearly labelled section (### block headers) # 3. Sub-tasks numbered sequentially (e.g., 4.1, 4.2, ..., 4.6) # 4. Console output uses consistent section headers via helper function # # COMMENTING: # 5. Comments explain the WHY (scientific rationale) not just the WHAT # 6. Statistical choices are justified BEFORE execution (not post-hoc) # 7. Assumptions are checked and discussed before each test # # CODING PRACTICES: # 8. Variable names are descriptive (season_data, cor_pearson, etc.) # 9. set.seed() ensures reproducibility of random subsamples # 10. Consistent use of tidyverse pipe syntax for data manipulation # 11. Explicit na.rm = TRUE throughout to handle missing data transparently # # VISUALISATION: # 12. All plots have meaningful titles, subtitles, and axis labels # 13. Colour palettes chosen for semantic meaning (blue=winter, etc.) # 14. Multiple plot types used appropriately (line, scatter, box, violin, # heatmap) with justification for each choice # 15. Two weather-pollutant relationships explored (Temp vs CO, RH vs NO2) # # STATISTICAL RIGOUR: # 16. Both Pearson and Spearman correlations compared for robustness # 17. Effect sizes reported alongside p-values (rank-biserial, epsilon-sq) # 18. Post-hoc corrections applied (Bonferroni) for multiple comparisons # 19. Confidence intervals and Hodges-Lehmann estimates included # 20. Standard third-moment skewness via e1071 (not Pearson approximation) # 21. Interpretation text generated from variables, not hardcoded section("ANALYSIS COMPLETE") cat("\n 11 plots saved to:", normalizePath(plot_dir), "\n") cat(" Files (named by task and plot number):\n") for (f in sort(list.files(plot_dir, pattern = "\\.png$"))) { cat(" ", f, "\n") } cat("\n Student ID :", student_id, "\n") cat(" Plots folder:", paste0(student_id, "-plots"), "(located next to this script)\n") cat("\n All tasks (1-5) executed successfully.\n\n")