Executive Summary
Is Monthly Revenue seasonal, and how big is the swing?
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.
Analysis Overview
STL seasonality decomposition of Monthly Revenue at a monthly cadence.
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
How the raw rows became a regular time series.
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.
Actual vs Trend
Monthly Revenue against its deseasonalized trend.
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.
Seasonal Index
Per-month deviation from the trend, in calendar order.
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.
Seasonal Decomposition
Per-month index and share of the seasonal deviation.
| Season | Periods Observed | Avg Effect | Index PCT | Share PCT |
|---|---|---|---|---|
| January | 4 | -1.681 | -0.75 | 1.2 |
| February | 4 | -3.433 | -1.52 | 2.5 |
| March | 4 | -2.45 | -1.09 | 1.8 |
| April | 4 | -2.984 | -1.32 | 2.2 |
| May | 4 | -1.863 | -0.83 | 1.4 |
| June | 4 | -47.43 | -21.03 | 34.4 |
| July | 4 | -1.27 | -0.56 | 0.9 |
| August | 4 | -2.117 | -0.94 | 1.5 |
| September | 4 | -2.203 | -0.98 | 1.6 |
| October | 4 | -2.099 | -0.93 | 1.5 |
| November | 4 | -1.406 | -0.62 | 1 |
| December | 4 | 68.94 | 30.57 | 50 |
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.
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 | monthly, inferred from the median gap of 31 day(s) between consecutive dates in 'Month' |
| Cycle length | 12 months per year |
| Cycles observed | 4 full year(s) across 48 monthly periods |
| Seasonal strength | 0.994 — 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 | 0.6% of the detrended variation is irregular remainder (noise), not repeating seasonality |
| Preprocessing | dates snapped to a regular monthly grid |
| Assumptions | Additive seasonality (a fixed effect per season in the metric's units); the seasonal shape is stable across the observed window |
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
Statistical methodology and diagnostics for Seasonality Decomposition
Statistical Method
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.
- 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
- 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)
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 '%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
)
}