Standard Seasonality
Executive Summary

Executive Summary

Is Monthly Revenue seasonal, and how big is the swing?

Seasonal Strength
0.994
Strength Band
strong
Peak Season
December
Trough Season
June
Cycles Observed
4
Trend Direction
upward
Monthly Revenue has a strong seasonal pattern (strength 0.99 on a 0-to-1 scale, from 4 full year-cycles of monthly data). The peak month is December, running about 30.6% above the trend level, and the trough is June, about 21.0% below it. The underlying trend is upward (+23.7% from the start of the window to the end). About 0.6% of the detrended variation is irregular noise rather than the repeating pattern.
Suggested Interpretation

The short answer

Monthly Revenue is highly seasonal, with a strong and repeating pattern across all 4 years. December peaks about 30.6% above the trend, and June dips about 21.0% below it. The underlying trend is rising by 23.7% over the window.

The detail

Seasonal strength is 0.994 (strong band, ≥0.60). Peak season: December at +30.6% above trend. Trough season: June at -21.0% below trend. The pattern is based on 4 full year-cycles of monthly data. The underlying trend direction is upward (+23.7% from start to end). About 0.6% of the detrended variation is irregular noise rather than the repeating pattern.

What this can't tell you

The trend strength and whether it will persist; the drivers of the December peak or June trough; or whether the seasonal amplitude is changing over time.

Overview

Analysis Overview

STL seasonality decomposition of Monthly Revenue at a monthly cadence.

N Observations48
Cycles Observed4
Frequencymonthly
Seasonal Strength0.994
Suggested Interpretation

The short answer

Your monthly revenue follows a strong, repeating seasonal pattern that explains 99.4% of the detrended variation—strong enough to plan around. The analysis split 48 months of data into trend, seasonal rhythm, and noise by inferring the monthly cadence from the dates themselves rather than assuming it.

The detail

The STL decomposition examined 48 observations spanning 4 complete year-cycles at monthly frequency. The seasonal strength score is 0.994, indicating the repeating per-month pattern dominates detrended fluctuation. The cadence (12-month cycle) was inferred from date spacing, not pre-specified.

What this can't tell you

This analysis identifies the seasonal rhythm but does not project future values or test whether the pattern will persist beyond the observed 4 years.

Data Preparation

Data Preparation

How the raw rows became a regular time series.

Initial Rows48
Final Rows48
N Duplicate Rows0
N Interpolated0
N Dropped0
Frequencymonthly
Suggested Interpretation

The short answer

All 48 rows loaded cleanly with no duplicates, gaps, or interpolation needed. The data was already a regular monthly series, so preprocessing simply regularized the dates to a monthly grid.

The detail

Initial rows: 48. Final rows: 48. No duplicate dates (0 aggregations) and no gaps to fill (0 interpolations). The cadence of monthly was inferred from the median gap of 31 days between consecutive dates. No rows were dropped. The final series contains 48 monthly observations spanning 4 full cycles.

What this can't tell you

This process reveals nothing about data quality issues, missing values in the underlying business process, or whether the reporting frequency matches the true operational cadence.

Visualization

Actual vs Trend

Monthly Revenue against its deseasonalized trend.

Suggested Interpretation

The short answer

Actual revenue oscillates around a rising trend line. The gaps between the two recur at the same months each year—largest in December (e.g., 276.63 in Dec 2021 vs trend, 290.88 in Dec 2022), deepest in June (161.01 in Jun 2021, 176.22 in Jun 2022)—confirming the seasonality is real and repeating, not random noise.

The detail

The chart plots Monthly Revenue (Actual) against the STL trend component. The vertical gaps between Actual and Trend are the seasonal pattern plus noise. Over the window, trend moves upward 23.7% from start to end. June consistently sits far below trend (e.g., 161.01, 176.22, 180.97 across the three full years shown), while December consistently peaks well above (276.63, 290.88). The consistency of these gaps across cycles confirms strong seasonality.

What this can't tell you

Whether the seasonal swings are widening or narrowing; the causes of the December spike or June dip; or confidence intervals around the trend itself.

Visualization

Seasonal Index

Per-month deviation from the trend, in calendar order.

Suggested Interpretation

The short answer

December is your peak month, running about 30.57% above trend, while June is your trough, running about -21.03% below trend. All other months cluster much closer to the trend line, making December and June the only months requiring material seasonal adjustment.

The detail

The seasonal index shows each month's deviation from trend in percentage terms. December reaches +30.57%, the highest bar. June falls to -21.03%, the lowest. The remaining ten months range from -1.52% (February) to -0.56% (July), all within ±2 percentage points of the trend. This concentration in two months reflects the 0.994 seasonal strength score.

What this can't tell you

The index describes the repeating rhythm but does not explain why December peaks and June dips, nor does it account for potential irregular events within those months.

Data Table

Seasonal Decomposition

Per-month index and share of the seasonal deviation.

SeasonPeriods ObservedAvg EffectIndex PCTShare PCT
January4-1.681-0.751.2
February4-3.433-1.522.5
March4-2.45-1.091.8
April4-2.984-1.322.2
May4-1.863-0.831.4
June4-47.43-21.0334.4
July4-1.27-0.560.9
August4-2.117-0.941.5
September4-2.203-0.981.6
October4-2.099-0.931.5
November4-1.406-0.621
December468.9430.5750
Suggested Interpretation

The short answer

December and June dominate the seasonal pattern: December carries 50% of the total seasonal deviation with an average effect of +68.937 units, while June carries 34.4% with an effect of -47.43 units. Together they account for 84.4% of all seasonal swing, leaving the other ten months nearly flat relative to trend.

The detail

December's avg_effect is +68.937 (index_pct: 30.57%, share_pct: 50.0%). June's avg_effect is -47.43 (index_pct: -21.03%, share_pct: 34.4%). All other months average between -3.433 and -1.27 units, with shares ranging from 0.9% to 2.5%. Each month was observed 4 times across the series.

What this can't tell you

This decomposition does not isolate whether the June dip and December peak are driven by the same underlying mechanism or separate drivers. It also cannot distinguish between true seasonal effects and one-time shocks that happened to occur in those months.

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 inferredmonthly, inferred from the median gap of 31 day(s) between consecutive dates in 'Month'
Cycle length12 months per year
Cycles observed4 full year(s) across 48 monthly periods
Seasonal strength0.994 — 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 share0.6% of the detrended variation is irregular remainder (noise), not repeating seasonality
Preprocessingdates snapped to a regular monthly grid
AssumptionsAdditive seasonality (a fixed effect per season in the metric's units); the seasonal shape is stable across the observed window
Suggested Interpretation

The short answer

The analysis uses STL decomposition, a standard statistical method that splits the series into a slow-moving trend, a fixed repeating seasonal pattern, and noise. The pattern is assumed additive and stable—meaning the same percentage swing happens every December and every June, regardless of the trend level.

The detail

Method: STL (seasonal-trend decomposition using loess) via stats::stl with s.window="periodic," producing one fixed seasonal shape across the 4 observed cycles. Cadence: monthly, inferred from the median gap of 31 days. Cycle length: 12 months. Seasonal strength formula: max(0, 1 − Var(remainder) / Var(seasonal + remainder)). Thresholds: weak <0.30, moderate 0.30–<0.60, strong ≥0.60. This series scores 0.994 (strong), with 0.6% of detrended variation as irregular noise. Preprocessing: dates snapped to a regular monthly grid. Assumption: additive, stable seasonal shape across all observed cycles.

What this can't tell you

Whether the seasonal pattern is growing or shrinking in magnitude; whether the pattern will hold outside the 4-year window; or the causal mechanisms behind the peaks and troughs.

Methodology

Methodology

Statistical methodology and diagnostics for Seasonality Decomposition

Statistical Method

Seasonality Decomposition

Standard-library analysis: find out whether a metric has a real seasonal pattern and how big it is. Maps a date column and a numeric column, infers the data's cadence (daily, weekly, monthly) from the date spacing, and runs an STL decomposition to split the series into trend, seasonal, and noise. Reports a 0-to-1 seasonal strength score with a plain-language band, names the peak and trough seasons in calendar terms with their percentage above and below trend, gives the trend direction over the window, and states honestly how much of the variation is just noise.

Data
N = 48 observations
Assumptions
  • Seasonality is additive: each season shifts the metric by a roughly fixed amount in its own units
  • The seasonal shape is stable across the observed window (STL periodic seasonal window)
  • Observations are (or can be aggregated to) a regular daily, weekly, or monthly cadence
Limitations
  • Needs just over two full cycles of history — 25 monthly periods for a yearly cycle — to separate seasonality from trend
  • A growing or shifting seasonal pattern is averaged into one fixed shape
  • Duplicate dates are summed and small gaps interpolated, which can smooth over real events
  • Describes the pattern; it does not forecast (use the Time Series Forecast tool for projections)
Software & Citation
MCP Analytics · mcpanalytics.ai
Code Appendix

Analysis Code

Complete R source code for this analysis

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 Signup Sign Up Free

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