Executive Summary
Is cnt seasonal, and how big is the swing?
Bike rentals show no reliable day-of-week pattern: seasonal strength is 0.04 (weak, well below the 0.30 threshold) across 104 week-cycles. Friday appears highest and Sunday lowest in the decomposed index, but 96.2% of the detrended variation is irregular noise, making these differences indistinguishable from random fluctuation. The underlying trend is upward. Day-of-week timing should not be used for planning.
Analysis Overview
STL seasonality decomposition of cnt at a daily cadence.
STL decomposition splits daily bike rental counts into three components: a slow-moving trend, a repeating day-of-week pattern, and noise. The 731 observations span 104 complete week-cycles, and the daily cadence was inferred automatically from the date spacing rather than assumed. Seasonal strength—a score from 0 to 1 measuring how much of the detrended variation the repeating pattern explains—registers 0.038 for this series, placing it firmly in the weak band. This score means the day-of-week rhythm, if present, is drowned out by irregular variation.
Data Preparation
How the raw rows became a regular time series.
The dataset arrived clean: 731 rows, each date appearing once, requiring no aggregation or gap-filling. The median gap between consecutive dates was 1 day, confirming a daily cadence and a 7-day seasonal cycle. No interpolation was needed; the final series contains all 731 original observations as a regular daily time series. This regularity and completeness eliminate data-shape artifacts that might otherwise inflate apparent seasonality.
Actual vs Trend
Cnt against its deseasonalized trend.
Actual daily rentals and the deseasonalized trend diverge by amounts that do not repeat consistently week to week, confirming weak seasonality. The trend component rises from the 2011 start through the end of the window, with actual rentals moving +115.8% from start to end, tracking this upward drift. The vertical gaps between actual and trend lines—which represent seasonal and noise components combined—wander erratically rather than returning to the same levels each week, a hallmark of noise-dominated variation.
Seasonal Index
Per-day of the week deviation from the trend, in calendar order.
The short answer
Weekly day-of-week patterns exist but are too weak to use for planning. Friday peaks at 3.86% above trend and Sunday dips to −5.73% below, yet the overall seasonal strength is only 0.04 — meaning these swings are mostly random noise rather than a reliable rhythm.
The detail
The seasonal index shows each day's deviation from trend in calendar order. Friday is the tallest bar at 3.86%, followed by Thursday at 3.42%. Sunday is the shortest at −5.73%, with Monday also depressed at −3.5%. The gap between Friday and Sunday is 9.59 percentage points. However, the seasonal strength score of 0.04 indicates that day-of-week effects are very weak — the card's own analysis states these bar heights are "mostly noise" and should not be read as a planning calendar.
What this can't tell you
This window captures day-of-week variation only; it does not address seasonal patterns across months or quarters. A longer observation window or monthly decomposition would be needed to detect broader seasonal cycles in bike rentals.
Seasonal Decomposition
Per-day of the week index and share of the seasonal deviation.
| Season | Periods Observed | Avg Effect | Index PCT | Share PCT |
|---|---|---|---|---|
| Monday | 105 | -157.6 | -3.5 | 18.9 |
| Tuesday | 104 | -0.793 | -0.02 | 0.1 |
| Wednesday | 104 | 33.9 | 0.75 | 4.1 |
| Thursday | 104 | 154 | 3.42 | 18.5 |
| Friday | 104 | 173.9 | 3.86 | 20.9 |
| Saturday | 105 | 54.94 | 1.22 | 6.6 |
| Sunday | 105 | -258.3 | -5.73 | 31 |
The short answer
Day-of-week effects are real but negligible. Friday has the largest positive effect at 173.922 rentals above trend, while Sunday has the largest negative effect at −258.308 below trend. Yet Sunday's outsized share (31%) of total seasonal deviation reflects weak overall strength — these swings are not consistent enough to drive operational planning.
The detail
Friday shows the highest average seasonal effect of 173.922 rentals (3.86% vs trend) and accounts for 20.9% of seasonal deviation. Thursday follows at 153.97 rentals (3.42%, 18.5% share). Sunday's −258.308 rental deficit (−5.73%, 31% share) is the largest single deviation, but the card notes that with weak overall strength, these shares "describe noise more than signal." Monday's −157.631 effect (−3.5%, 18.9% share) is the second-largest negative contributor. Tuesday is negligible at −0.793 rentals (−0.02%, 0.1% share).
What this can't tell you
This decomposition is limited to observed day-of-week structure; it cannot separate true weekly rhythm from random week-to-week variation. The 105 periods observed per day provide moderate coverage, but a longer window or external validation (e.g., holiday calendars) would strengthen confidence in whether these patterns persist or shift over time.
Method & Assumptions
How the decomposition works, the strength formula, and the fine print.
| Item | Detail |
|---|---|
| Decomposition | STL (seasonal-trend decomposition using loess), stats::stl with s.window set to "periodic" — one fixed seasonal shape across the window |
| Cadence inferred | daily, inferred from the median gap of 1 day(s) between consecutive dates in 'dteday' |
| Cycle length | 7 days per week |
| Cycles observed | 104 full week(s) across 731 daily periods |
| Seasonal strength | 0.038 — defined as max(0, 1 minus Var(remainder) divided by Var(seasonal plus remainder)) |
| Strength thresholds | weak below 0.30, moderate 0.30 to just under 0.60, strong at 0.60 or above |
| Seasonal index units | percent of the trend level |
| Noise share | 96.2% of the detrended variation is irregular remainder (noise), not repeating seasonality |
| Preprocessing | dates snapped to a regular daily grid |
| Assumptions | Additive seasonality (a fixed effect per season in the metric's units); the seasonal shape is stable across the observed window |
STL decomposes each observation into trend, a fixed per-day-of-week seasonal component, and remainder. Seasonal strength is calculated as max(0, 1 − Var(remainder) ÷ Var(seasonal + remainder)), with thresholds: weak below 0.30, moderate 0.30 to just under 0.60, strong at 0.60 or above. This series scores 0.038 (weak). The cadence (daily) and cycle length (7 days) were inferred from the observed date spacing. The seasonal shape is assumed additive and stable—a pattern growing or shifting over the 104 cycles will be averaged into one fixed shape, potentially masking evolution. Preprocessing involved snapping dates to a regular grid; no aggregation or interpolation occurred.
Seasonality Decomposition — Is There a Seasonal Pattern, and How Big Is It?
Decomposes a dated metric into trend, seasonal, and remainder components with STL (seasonal-trend decomposition using loess, stats::stl, s.window set to "periodic"). Infers the data's cadence from the date spacing (daily, weekly, monthly), quantifies seasonal strength on a 0-to-1 scale with a plain-language band, names the peak and trough seasons in calendar terms with their percentage above/below the trend level, and reports the trend direction and the share of variation that is irregular noise.
Why This Method?
STL is the standard, robust way to separate a repeating seasonal cycle from trend and noise: it makes no distributional assumptions, handles any of the three common cadences, and yields a directly interpretable seasonal index per season. Base R only (stats::stl).
What This Analysis Covers
- Actual vs trend line chart
- Seasonal index per season (bar chart, calendar order)
- Full per-season decomposition table
- Seasonal strength with thresholds, plus an honest noise share
Standard Library
Platform standard-library module (LAT-1441): runs on ANY dataset via the semantic mapping {date, value}. All narrative is derived from the user's own column names and computed values.
suppressPackageStartupMessages(library(DT))
suppressPackageStartupMessages(library(htmlwidgets))
suppressPackageStartupMessages(library(arrow))
suppressPackageStartupMessages(library(knitr))
suppressPackageStartupMessages(library(rmarkdown))
suppressPackageStartupMessages(library(dplyr))
suppressPackageStartupMessages(library(tidyr))
suppressPackageStartupMessages(library(ggplot2))
suppressPackageStartupMessages(library(stringr))
suppressPackageStartupMessages(library(lubridate))
suppressPackageStartupMessages(library(broom))
suppressPackageStartupMessages(library(Matrix))
suppressPackageStartupMessages(library(cluster))
suppressPackageStartupMessages(library(data.table))Core Analysis Pipeline
Step 1: Parse dates robustly (init() may already have Date-coerced)
raw_date <- df$date
non_blank <- !(is.na(raw_date) | !nzchar(trimws(as.character(raw_date))))
d <- parse_dates_robust(raw_date)
n_nonblank <- sum(non_blank)
if (n_nonblank == 0) {
stop(sprintf("The '%s' column is empty — no dates to decompose over.", date_name))
}
n_unparsed <- sum(non_blank & is.na(d))
if (n_unparsed / n_nonblank > 0.05) {
stop(sprintf(
"%d of %d values in '%s' (%.0f%%) could not be read as dates. Please use a recognizable date format (e.g. 2024-01-31 or 01/31/2024).",
n_unparsed, n_nonblank, date_name, 100 * n_unparsed / n_nonblank))
}Step 2: Coerce the metric (95% rule)
v <- df$value
if (!is.numeric(v)) {
conv <- suppressWarnings(as.numeric(as.character(v)))
n_orig <- sum(!is.na(v) & nzchar(as.character(v)))
if (n_orig > 0 && sum(!is.na(conv)) >= 0.95 * n_orig) {
v <- conv
} else {
stop(sprintf("The '%s' column is not numeric — a seasonality decomposition needs a numeric metric.",
value_name))
}
}
v <- as.numeric(v)
keep <- !is.na(d) & !is.na(v)
n_dropped_rows <- initial_rows - sum(keep)
work <- data.frame(date = d[keep], value = v[keep])Step 4: Infer frequency from the median date gap.
daily data cycles weekly (7 periods); weekly and monthly data cycle yearly (52 and 12 periods). The inference is stated in every report.
gaps <- as.numeric(diff(agg$date))
med_gap <- median(gaps)
if (med_gap <= 1.5) {
freq_label <- "daily"; step_days <- 1; freq <- 7
period_word <- "day"; cycle_word <- "week"; season_word <- "day of the week"
} else if (med_gap >= 5.5 && med_gap <= 8.5) {
freq_label <- "weekly"; step_days <- 7; freq <- 52
period_word <- "week"; cycle_word <- "year"; season_word <- "week of the year"
} else if (med_gap >= 26 && med_gap <= 35) {
freq_label <- "monthly"; step_days <- NA; freq <- 12
period_word <- "month"; cycle_word <- "year"; season_word <- "month"
} else {
stop(sprintf(
"The spacing of '%s' (median gap of %s days between consecutive dates) is not a daily, weekly, or monthly cadence — a seasonal decomposition needs one of those three regular cadences.",
date_name, fmt_num(med_gap, 0)))
}Step 5: Regular grid + linear interpolation of small gaps
if (freq_label == "monthly") {
snapped <- as.Date(format(agg$date, "%Y-%m-01"))
agg <- aggregate(value ~ date, data = data.frame(date = snapped, value = agg$value),
FUN = sum)
agg <- agg[order(agg$date), , drop = FALSE]
grid <- seq(min(agg$date), max(agg$date), by = "month")
} else {
grid <- seq(min(agg$date), max(agg$date), by = step_days)
}
interp <- approx(x = as.numeric(agg$date), y = agg$value,
xout = as.numeric(grid), rule = 2)
values <- interp$y
n_interp <- sum(!(as.numeric(grid) %in% as.numeric(agg$date)))
if (n_interp / length(grid) > 0.5) {
stop(sprintf(
"More than half of the %s periods between the first and last '%s' are missing — too sparse to decompose reliably.",
freq_label, date_name))
}
n_points <- length(values)
final_rows <- n_points
rows_removed <- max(0, initial_rows - sum(keep))
cycles_observed <- floor(n_points / freq)Step 6: Require just over two full cycles (the STL minimum: the
number of periods must exceed twice the cycle length) — else stop gracefully and say exactly how much data is needed.
min_needed <- 2 * freq + 1
if (n_points < min_needed) {
stop(sprintf(
"Only %d %s periods of '%s' — separating a seasonal pattern from trend at a %s cadence needs at least %d %s periods (just over two full %ss of %d %ss each). Add more history and rerun.",
n_points, freq_label, value_name, freq_label, min_needed, freq_label,
cycle_word, freq, period_word))
}Step 7: Constant series — graceful zero-seasonality path (no STL,
which would divide by zero variance)
is_constant <- is.na(sd(values)) || sd(values) < 1e-9
if (is_constant) {
seasonal_comp <- rep(0, n_points)
trend_comp <- values
remainder_comp <- rep(0, n_points)
} else {Step 8: STL decomposition (stats::stl, s.window = "periodic")
y <- ts(values, frequency = freq)
fit <- stl(y, s.window = "periodic")
seasonal_comp <- as.numeric(fit$time.series[, "seasonal"])
trend_comp <- as.numeric(fit$time.series[, "trend"])
remainder_comp <- as.numeric(fit$time.series[, "remainder"])
}Step 9: Seasonal strength = max(0, 1 - Var(remainder)/Var(seasonal+remainder))
Bands: weak below 0.3, moderate below 0.6, strong at or above 0.6.
detrended_var <- var(seasonal_comp + remainder_comp)
if (is.na(detrended_var) || detrended_var < 1e-12) {
strength <- 0
noise_share_pct <- 0
} else {
strength <- max(0, 1 - var(remainder_comp) / detrended_var)
noise_share_pct <- min(100, 100 * var(remainder_comp) / detrended_var)
}
band <- if (strength >= 0.6) "strong" else if (strength >= 0.3) "moderate" else "weak"Step 10: Trend over the window
trend_level <- mean(trend_comp)
trend_change <- trend_comp[n_points] - trend_comp[1]
trend_pct <- if (abs(trend_comp[1]) > 1e-9) 100 * trend_change / abs(trend_comp[1]) else NA_real_
idx <- seq_len(n_points)
trend_slope <- as.numeric(coef(lm(trend_comp ~ idx))[2])
direction_word <- if (!is.na(trend_pct)) {
if (trend_pct > 2) "upward" else if (trend_pct < -2) "downward" else "roughly flat"
} else {
if (trend_slope > 1e-9) "upward" else if (trend_slope < -1e-9) "downward" else "roughly flat"
}Step 11: Per-season index in calendar order.
Seasons are grouped by CALENDAR position of each grid date (month, weekday, or ISO week), so the labels are true calendar terms and the output rows are emitted in calendar order — never alphabetical.
if (freq_label == "monthly") {
season_key <- as.integer(format(grid, "%m"))
key_levels <- 1:12
key_abbr <- month.abb
key_full <- month.name
} else if (freq_label == "daily") {
season_key <- as.integer(format(grid, "%u")) # 1 = Monday .. 7 = Sunday
key_levels <- 1:7
key_abbr <- c("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
key_full <- c("Monday", "Tuesday", "Wednesday", "Thursday", "Friday",
"Saturday", "Sunday")
} else {
season_key <- as.integer(format(grid, "%V")) # ISO week 1..53
key_levels <- sort(unique(season_key))
key_abbr <- sprintf("Wk %02d", key_levels)
key_full <- sprintf("Week %d", key_levels)
}
present <- key_levels[key_levels %in% unique(season_key)]
mean_effect <- sapply(present, function(k) mean(seasonal_comp[season_key == k]))
n_per_season <- sapply(present, function(k) sum(season_key == k))
index_is_pct <- abs(trend_level) > 1e-9
index_vals <- if (index_is_pct) 100 * mean_effect / abs(trend_level) else mean_effect
abs_sum <- sum(abs(mean_effect))
share_vals <- if (abs_sum > 1e-12) 100 * abs(mean_effect) / abs_sum else rep(0, length(mean_effect))
season_abbr <- key_abbr[match(present, key_levels)]
season_full <- key_full[match(present, key_levels)]Peak and trough: filter NA before which.max/which.min (LAT-1445 class)
ok <- which(!is.na(index_vals))
if (is_constant || length(ok) == 0 || abs_sum < 1e-12) {
peak_label <- "None"; trough_label <- "None"
peak_pct <- 0; trough_pct <- 0
} else {
hi <- ok[which.max(index_vals[ok])]
lo <- ok[which.min(index_vals[ok])]
peak_label <- season_full[hi]; peak_pct <- index_vals[hi]
trough_label <- season_full[lo]; trough_pct <- index_vals[lo]
}Step 13: Metrics + computed answer
strength_metric <- round(strength, 3)
metrics <- list(
`Seasonal Strength` = strength_metric,
`Strength Band` = band,
`Peak Season` = peak_label,
`Trough Season` = trough_label,
`Cycles Observed` = cycles_observed,
`Trend Direction` = direction_word
)
seasonal_claim <- if (is_constant) {
sprintf("'%s' is constant over the whole window, so there is no seasonal pattern (and no trend or noise) to decompose.", value_name)
} else if (band == "weak") {
sprintf("no reliable seasonal pattern: seasonal strength is %s(weak, below 0.30), so the apparent per-%s differences are indistinguishable from noise.",
fmt_num(strength, 2), season_word)
} else {
paste0(
sprintf("a %s seasonal pattern(strength %s): %s peaks about %s%% above the trend level and %s runs about %s%% below it. ",
band, fmt_num(strength, 2), peak_label, fmt_num(abs(peak_pct), 1),
trough_label, fmt_num(abs(trough_pct), 1)),
sprintf("The underlying trend is %s over the window", direction_word),
if (!is.na(trend_pct)) sprintf(" (%s%s%% from start to end).", if (trend_change >= 0) "+" else "-", fmt_num(abs(trend_pct), 1)) else "."
)
}
json_output <- list(
answer = paste0(
"Seasonality decomposition of ", value_name, " (", n_points, " ", freq_label,
" periods, ", cycles_observed, " full ", cycle_word, "-cycles, STL): ",
seasonal_claim,
sprintf(" About %s%% of the detrended variation is irregular noise.",
fmt_num(noise_share_pct, 1))
),
cards = lapply(
c("tldr", "overview", "preprocessing", "actual_vs_trend",
"seasonal_index", "decomposition_table", "methods"),
function(cid) list(id = cid, metrics = metrics)
)
)
list(
initial_rows = initial_rows, final_rows = final_rows,
rows_removed = rows_removed,
date_name = date_name, value_name = value_name,
freq_label = freq_label, freq = freq, period_word = period_word,
cycle_word = cycle_word, season_word = season_word,
med_gap = med_gap,
n_points = n_points, cycles_observed = cycles_observed,
n_dup_rows = n_dup_rows, n_interp = n_interp,
n_unparsed = n_unparsed, n_dropped_rows = n_dropped_rows,
is_constant = is_constant,
strength = strength, band = band, noise_share_pct = noise_share_pct,
trend_level = trend_level, trend_change = trend_change,
trend_pct = trend_pct, trend_slope = trend_slope,
direction_word = direction_word,
peak_label = peak_label, peak_pct = peak_pct,
trough_label = trough_label, trough_pct = trough_pct,
index_is_pct = index_is_pct,
actual_vs_trend_df = actual_vs_trend_df,
seasonal_index_df = seasonal_index_df,
decomposition_df = decomposition_df,
methods_df = methods_df,
metrics = metrics, json_output = json_output
)
}