We use the built-in airquality dataset in R.
The goal is to predict Ozone levels using the other
weather variables.
| Variable | Meaning |
|---|---|
| Ozone | Ozone concentration (ppb) — outcome |
| Solar.R | Solar radiation (lang) |
| Wind | Wind speed (mph) |
| Temp | Temperature (°F) |
| Month | Month (5 = May … 9 = September) |
| Day | Day of the month |
# Load the airquality dataset that is built into R
data(airquality)
# Show the first 6 rows so we can see what the data looks like
head(airquality)## Ozone Solar.R Wind Temp Month Day
## 1 41 190 7.4 67 5 1
## 2 36 118 8.0 72 5 2
## 3 12 149 12.6 74 5 3
## 4 18 313 11.5 62 5 4
## 5 NA NA 14.3 56 5 5
## 6 28 NA 14.9 66 5 6
## Ozone Solar.R Wind Temp
## Min. : 1.00 Min. : 7.0 Min. : 1.700 Min. :56.00
## 1st Qu.: 18.00 1st Qu.:115.8 1st Qu.: 7.400 1st Qu.:72.00
## Median : 31.50 Median :205.0 Median : 9.700 Median :79.00
## Mean : 42.13 Mean :185.9 Mean : 9.958 Mean :77.88
## 3rd Qu.: 63.25 3rd Qu.:258.8 3rd Qu.:11.500 3rd Qu.:85.00
## Max. :168.00 Max. :334.0 Max. :20.700 Max. :97.00
## NAs :37 NAs :7
## Month Day
## Min. :5.000 Min. : 1.0
## 1st Qu.:6.000 1st Qu.: 8.0
## Median :7.000 Median :16.0
## Mean :6.993 Mean :15.8
## 3rd Qu.:8.000 3rd Qu.:23.0
## Max. :9.000 Max. :31.0
##
## Ozone Solar.R Wind Temp Month Day
## 37 7 0 0 0 0
# Remove rows that have any missing value so the model can run cleanly
air_clean <- na.omit(airquality)
# Confirm how many rows we have after removing missing values
nrow(air_clean)## [1] 111
# Load ggplot2 for nicer charts
library(ggplot2)
# Load patchwork so we can display multiple charts side by side
library(patchwork)
# Chart 1: Ozone vs Temperature — we expect a positive relationship
p1 <- ggplot(air_clean, aes(x = Temp, y = Ozone)) +
geom_point(color = "steelblue", alpha = 0.7) + # draw scatter points
geom_smooth(method = "lm", color = "red", se = TRUE) + # add a trend line
labs(title = "Ozone vs Temperature",
x = "Temperature (°F)", y = "Ozone (ppb)") +
theme_minimal()
# Chart 2: Ozone vs Wind — we expect a negative relationship
p2 <- ggplot(air_clean, aes(x = Wind, y = Ozone)) +
geom_point(color = "darkorange", alpha = 0.7) +
geom_smooth(method = "lm", color = "red", se = TRUE) +
labs(title = "Ozone vs Wind",
x = "Wind Speed (mph)", y = "Ozone (ppb)") +
theme_minimal()
# Chart 3: Ozone vs Solar Radiation
p3 <- ggplot(air_clean, aes(x = Solar.R, y = Ozone)) +
geom_point(color = "forestgreen", alpha = 0.7) +
geom_smooth(method = "lm", color = "red", se = TRUE) +
labs(title = "Ozone vs Solar Radiation",
x = "Solar Radiation (lang)", y = "Ozone (ppb)") +
theme_minimal()
# Chart 4: Histogram of Ozone — check if it is roughly bell-shaped
p4 <- ggplot(air_clean, aes(x = Ozone)) +
geom_histogram(bins = 20, fill = "purple", alpha = 0.7, color = "white") +
labs(title = "Distribution of Ozone", x = "Ozone (ppb)", y = "Count") +
theme_minimal()
# Display all four charts in a 2x2 grid
(p1 | p2) / (p3 | p4)# Compute the correlation between Ozone and every other numeric variable
# Values close to +1 or -1 show a strong relationship
cor(air_clean[, c("Ozone", "Solar.R", "Wind", "Temp")],
use = "complete.obs")## Ozone Solar.R Wind Temp
## Ozone 1.0000000 0.3483417 -0.6124966 0.6985414
## Solar.R 0.3483417 1.0000000 -0.1271835 0.2940876
## Wind -0.6124966 -0.1271835 1.0000000 -0.4971897
## Temp 0.6985414 0.2940876 -0.4971897 1.0000000
# Build the multiple linear regression model
# Ozone is the outcome (left of ~)
# Solar.R, Wind, and Temp are the predictors (right of ~)
model <- lm(Ozone ~ Solar.R + Wind + Temp, data = air_clean)
# Show the full model results (coefficients, p-values, R-squared, etc.)
summary(model)##
## Call:
## lm(formula = Ozone ~ Solar.R + Wind + Temp, data = air_clean)
##
## Residuals:
## Min 1Q Median 3Q Max
## -40.485 -14.219 -3.551 10.097 95.619
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) -64.34208 23.05472 -2.791 0.00623 **
## Solar.R 0.05982 0.02319 2.580 0.01124 *
## Wind -3.33359 0.65441 -5.094 1.52e-06 ***
## Temp 1.65209 0.25353 6.516 2.42e-09 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 21.18 on 107 degrees of freedom
## Multiple R-squared: 0.6059, Adjusted R-squared: 0.5948
## F-statistic: 54.83 on 3 and 107 DF, p-value: < 2.2e-16
| Predictor | Coefficient | Plain-English Meaning |
|---|---|---|
| Intercept | -64.342 | Predicted Ozone when all predictors = 0 |
| Solar.R | 0.06 | Each extra lang of solar radiation adds 0.06 ppb of Ozone |
| Wind | -3.334 | Each extra mph of wind reduces Ozone by 3.334 ppb |
| Temp | 1.652 | Each extra °F of temperature adds 1.652 ppb of Ozone |
Key take-aways from the summary output:
Solar.R — small positive effect; statistically
significant (p < 0.05).
More sunshine → slightly more Ozone.
Wind — strong negative effect; highly
significant (p < 0.001).
Stronger winds blow Ozone away, lowering its concentration.
Temp — strong positive effect; highly
significant (p < 0.001).
Hotter days produce more Ozone (heat drives chemical reactions in the
air).
R² ≈ 0.61 — the three predictors together explain about 61 % of the variation in Ozone levels.
# The four standard diagnostic plots for a linear regression model
# They help us check whether the model assumptions are met
par(mfrow = c(2, 2)) # arrange 4 plots in a 2x2 grid
# Plot 1 – Residuals vs Fitted: checks if the relationship is truly linear
# Plot 2 – Q-Q plot: checks if residuals are normally distributed
# Plot 3 – Scale-Location: checks if variance is roughly constant
# Plot 4 – Residuals vs Leverage: spots influential outliers
plot(model)What to look for:
| Plot | What we want to see |
|---|---|
| Residuals vs Fitted | Points scattered randomly around the 0 line |
| Normal Q-Q | Points lying close to the diagonal line |
| Scale-Location | A roughly flat red line (constant spread) |
| Residuals vs Leverage | No points far outside Cook’s distance lines |
# Create a small table of new weather conditions we want to predict for
new_days <- data.frame(
Solar.R = c(150, 200, 250), # three different solar radiation levels
Wind = c(10, 8, 5), # three different wind speeds
Temp = c(75, 80, 90) # three different temperatures
)
# Use the fitted model to predict Ozone for these new days
predictions <- predict(model,
newdata = new_days,
interval = "confidence", # add 95 % confidence bounds
level = 0.95)
# Combine the new conditions with the predictions for easy reading
result_table <- cbind(new_days, round(predictions, 1))
# Rename columns so they are easy to understand
colnames(result_table) <- c("Solar.R", "Wind", "Temp",
"Predicted Ozone", "Lower 95%", "Upper 95%")
# Print the final prediction table
knitr::kable(result_table, caption = "Predicted Ozone levels for new weather conditions")| Solar.R | Wind | Temp | Predicted Ozone | Lower 95% | Upper 95% |
|---|---|---|---|---|---|
| 150 | 10 | 75 | 35.2 | 30.8 | 39.6 |
| 200 | 8 | 80 | 53.1 | 48.6 | 57.7 |
| 250 | 5 | 90 | 82.6 | 75.1 | 90.1 |
Using multiple linear regression on the airquality dataset, we found that:
The model diagnostics show acceptable (though not perfect) behaviour — there is slight non-linearity and a few high-leverage points, which is normal for atmospheric data.