Standard Seasonality
Executive Summary

Executive Summary

Is cnt seasonal, and how big is the swing?

Seasonal Strength
0.038
Strength Band
weak
Peak Season
Friday
Trough Season
Sunday
Cycles Observed
104
Trend Direction
upward
No reliable seasonal pattern in cnt: seasonal strength is 0.04 (weak — below the 0.30 threshold) across 104 observed week-cycles. The per-day of the week differences that do appear are indistinguishable from noise, so they should not be planned around. The underlying trend over the window is upward. About 96.2% of the detrended variation is irregular noise.
What this means

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.

Overview

Analysis Overview

STL seasonality decomposition of cnt at a daily cadence.

N Observations731
Cycles Observed104
Frequencydaily
Seasonal Strength0.038
What this means

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

Data Preparation

How the raw rows became a regular time series.

Initial Rows731
Final Rows731
N Duplicate Rows0
N Interpolated0
N Dropped0
Frequencydaily
What this means

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.

Visualization

Actual vs Trend

Cnt against its deseasonalized trend.

What this means

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.

Visualization

Seasonal Index

Per-day of the week deviation from the trend, in calendar order.

What this means

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.

Data Table

Seasonal Decomposition

Per-day of the week index and share of the seasonal deviation.

SeasonPeriods ObservedAvg EffectIndex PCTShare PCT
Monday105-157.6-3.518.9
Tuesday104-0.793-0.020.1
Wednesday10433.90.754.1
Thursday1041543.4218.5
Friday104173.93.8620.9
Saturday10554.941.226.6
Sunday105-258.3-5.7331
What this means

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.

Data Table

Method & Assumptions

How the decomposition works, the strength formula, and the fine print.

ItemDetail
DecompositionSTL (seasonal-trend decomposition using loess), stats::stl with s.window set to "periodic" — one fixed seasonal shape across the window
Cadence inferreddaily, inferred from the median gap of 1 day(s) between consecutive dates in 'dteday'
Cycle length7 days per week
Cycles observed104 full week(s) across 731 daily periods
Seasonal strength0.038 — defined as max(0, 1 minus Var(remainder) divided by Var(seasonal plus remainder))
Strength thresholdsweak below 0.30, moderate 0.30 to just under 0.60, strong at 0.60 or above
Seasonal index unitspercent of the trend level
Noise share96.2% of the detrended variation is irregular remainder (noise), not repeating seasonality
Preprocessingdates snapped to a regular daily grid
AssumptionsAdditive seasonality (a fixed effect per season in the metric's units); the seasonal shape is stable across the observed window
What this means

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.

Rate this report Was this the answer you needed?
The exact source that produced this report — yours to keep, read, and re-run.
Download PDF
How this was computed method · R source · citation
The code that did it

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 &#x27;%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 &#x27;%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 &#x27;%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 &#x27;%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 &#x27;%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 &#x27;%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("&#x27;%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
  )
}
Your data has more stories to tell.Run any analysis on your own data — validated R modules, interactive reports, AI insights, and PDF export. 500 free credits on signup.
Try Free — No SignupSign Up Free

Cite this analysis

Report an Issue

Tell us what's wrong. You'll get a free re-run of this analysis so you can try again with different parameters. If the re-run still doesn't meet your expectations, we'll refund your credits.

Want to run this analysis on your own data? Upload CSV — Free Analysis See Pricing