A B2B software company counts qualified trial signups every month. It is January 2026 and the December numbers have just landed. Signups fell by more than a third against November, the worst month-over-month drop on the books, and the leadership channel has a thread on it.
Two questions are on the table, and only one of them is the real one:
Is December a problem?
Is anything a problem?
This review answers the first in one paragraph and then spends the rest of its length on the second, because the answers point in opposite directions. December is fine. December is always like that. What is actually wrong started nine months earlier, in a month nobody looked at twice, and no month-over-month comparison in this dataset could have shown it.
One row per month, 48 consecutive months, no gaps.
| column | meaning | unit |
|---|---|---|
month_start |
first day of the month | date |
trial_signups |
qualified trial signups booked that month | count |
The series is simulated to a stated structure so
this document can teach against a known answer: a trend that rises for
forty months and then turns over, a fixed annual seasonal shape with its
peak in September and its trough in December, and multiplicative
month-to-month noise. Generation is deterministic
(set.seed(20260825)); re-knitting reproduces
data.csv byte-for-byte. Everything below is computed from
the written-out file.
Counts are drawn from a Poisson with a lognormal multiplier, so no figure here is chosen. The parameters make the teaching point; the results fall where the seed puts them.
set.seed(20260825)
n <- 48
m <- 1:n
mo <- ((m - 1) %% 12) + 1
dates <- seq(as.Date("2022-01-01"), by = "month", length.out = n)
# The trend: up for 40 months, then over. The turn is what the review has to find.
trend_true <- 1840 + 38 * pmin(m, 40) - 70 * pmax(m - 40, 0)
# The season: a fixed annual shape. September is the budget-season peak, December the trough.
seasonal_index <- c(0.94, 1.02, 1.08, 1.01, 0.97, 0.86,
0.79, 0.95, 1.31, 1.18, 1.06, 0.66)
trial_signups <- rpois(n, trend_true * seasonal_index[mo] * exp(rnorm(n, 0, 0.075)))
d <- data.frame(month_start = dates, trial_signups = trial_signups)
write.csv(d, "data.csv", row.names = FALSE)
d <- read.csv("data.csv")
d$month_start <- as.Date(d$month_start)
str(d)
## 'data.frame': 48 obs. of 2 variables:
## $ month_start : Date, format: "2022-01-01" "2022-02-01" ...
## $ trial_signups: int 1906 1842 2152 2234 1869 1878 1515 1831 3104 2500 ...
Every claim below assumes the series is regular. A seasonal decomposition given an irregular series will still return a seasonal component, so this has to be checked rather than assumed.
gaps <- diff(as.numeric(format(d$month_start, "%Y")) * 12 +
as.numeric(format(d$month_start, "%m")))
stopifnot(
nrow(d) == 48, # four whole years
all(gaps == 1), # consecutive months, no gaps, no duplicates
!any(is.na(d$trial_signups)), # no missing values to interpolate over
all(d$trial_signups > 0), # counts, all positive
nrow(d) >= 24 # at least two full periods, or there is no season to find
)
data.frame(
months = nrow(d),
from = format(min(d$month_start), "%Y-%m"),
to = format(max(d$month_start), "%Y-%m"),
total = sum(d$trial_signups),
smallest = min(d$trial_signups),
largest = max(d$trial_signups)
)
| months | from | to | total | smallest | largest |
|---|---|---|---|---|---|
| 48 | 2022-01 | 2025-12 | 125933 | 1515 | 3975 |
The December thread is built on one number, so start there.
dec25 <- d$trial_signups[48]; nov25 <- d$trial_signups[47]; dec24 <- d$trial_signups[36]
data.frame(
comparison = c("Dec 2025 vs Nov 2025", "Dec 2025 vs Dec 2024"),
from = c(nov25, dec24),
to = c(dec25, dec25),
change_pct = round(c(dec25 / nov25 - 1, dec25 / dec24 - 1) * 100, 4)
)
| comparison | from | to | change_pct |
|---|---|---|---|
| Dec 2025 vs Nov 2025 | 2958 | 1768 | -40.2299 |
| Dec 2025 vs Dec 2024 | 2206 | 1768 | -19.8549 |
A fall of 40.2299% is a real number, correctly computed, and it means nothing on its own. Look at every December in the file:
d$month <- as.integer(format(d$month_start, "%m"))
d$year <- as.integer(format(d$month_start, "%Y"))
dec <- d[d$month == 12, c("year", "trial_signups")]
nov <- d[d$month == 11, c("year", "trial_signups")]
mrg <- merge(nov, dec, by = "year", suffixes = c("_nov", "_dec"))
mrg$mom_pct <- round((mrg$trial_signups_dec / mrg$trial_signups_nov - 1) * 100, 4)
mrg
| year | trial_signups_nov | trial_signups_dec | mom_pct |
|---|---|---|---|
| 2022 | 2352 | 1641 | -30.2296 |
| 2023 | 2703 | 1834 | -32.1495 |
| 2024 | 3076 | 2206 | -28.2835 |
| 2025 | 2958 | 1768 | -40.2299 |
December falls by about a third every single year, and the four largest month-over-month falls in the entire file are the four Decembers. The 2025 drop is the deepest of them, but “deepest December” is a rank among four, not evidence of a problem. This is the failure the whole topic exists for: a month-over-month comparison between two months that were never comparable.
plot(d$month_start, d$trial_signups, type = "o", pch = 16, lwd = 2,
col = "#3aa0e0", xlab = "", ylab = "qualified trial signups",
main = "Monthly trial signups, 2022-2025")
grid(col = "#e8e8e8")
Four peaks, four troughs, same months every year. Nobody looking at this shape would open a thread about December. The harder question is whether anything else in it is worth a thread, and the eye is not good at that: the annual swing is large enough to hide almost any trend inside it.
stl() separates the series into three additive parts — a
smooth trend, a repeating seasonal
component, and a remainder that is neither.
s.window = "periodic" holds the seasonal shape fixed across
years, which is the right assumption for a calendar-driven business
rhythm and the assumption the platform tool makes.
ts_signups <- ts(d$trial_signups, frequency = 12, start = c(2022, 1))
fit <- stl(ts_signups, s.window = "periodic")
comp <- as.data.frame(fit$time.series)
plot(fit, main = "STL decomposition: data = trend + seasonal + remainder")
Seasonal and trend strength are variance ratios on the components (Wang, Smith and Hyndman): compare how much variation is left in the remainder against how much there is in the remainder plus the component you are asking about. Both land in 0 to 1, and neither is a p-value.
V_rem <- var(comp$remainder)
seasonal_strength <- max(0, 1 - V_rem / var(comp$seasonal + comp$remainder))
trend_strength <- max(0, 1 - V_rem / var(comp$trend + comp$remainder))
noise_share <- V_rem / var(d$trial_signups)
data.frame(
measure = c("seasonal strength", "trend strength", "remainder share of total variance"),
value = round(c(seasonal_strength, trend_strength, noise_share), 6)
)
| measure | value |
|---|---|
| seasonal strength | 0.908410 |
| trend strength | 0.868536 |
| remainder share of total variance | 0.057531 |
A seasonal strength of 0.9084 says the annual pattern is not a story someone is telling about the data; it is most of the data. A remainder holding 5.75% of the variance says the month-to-month wobble is small against everything else, which is exactly why the December drop felt alarming: at this noise level, a third is not noise. It is the calendar.
seas <- comp$seasonal[1:12]
names(seas) <- month.abb
mean_trend <- mean(comp$trend)
shape <- data.frame(
month = month.abb,
seasonal_abs = round(as.numeric(seas), 2),
pct_of_trend = round(as.numeric(seas) / mean_trend * 100, 4)
)
shape[order(-shape$pct_of_trend), ]
| month | seasonal_abs | pct_of_trend | |
|---|---|---|---|
| 9 | Sep | 1029.94 | 39.3841 |
| 10 | Oct | 466.06 | 17.8220 |
| 3 | Mar | 288.28 | 11.0238 |
| 4 | Apr | 229.31 | 8.7685 |
| 11 | Nov | 81.69 | 3.1238 |
| 5 | May | 18.08 | 0.6913 |
| 2 | Feb | -12.56 | -0.4804 |
| 1 | Jan | -140.91 | -5.3884 |
| 8 | Aug | -203.51 | -7.7822 |
| 6 | Jun | -283.32 | -10.8339 |
| 7 | Jul | -631.72 | -24.1564 |
| 12 | Dec | -841.34 | -32.1722 |
barplot(as.numeric(seas) / mean_trend * 100, names.arg = month.abb,
col = ifelse(as.numeric(seas) >= 0, "#3aa0e0", "#e08a3a"),
border = NA, ylab = "% above / below trend",
main = "The annual shape, as a share of trend")
abline(h = 0, col = "#555")
September runs 39.3841% above trend and December -32.1722% below it. Peak to trough is 71.5564 points of trend, and that number is the one to carry into the next section.
With the season removed, the trend component can be read directly.
peak_i <- which.max(comp$trend)
data.frame(
fact = c("trend peaked", "trend at peak", "trend now (Dec 2025)",
"change since peak (%)", "change across whole window (%)"),
value = c(format(d$month_start[peak_i], "%Y-%m"),
sprintf("%.2f", comp$trend[peak_i]),
sprintf("%.2f", comp$trend[48]),
sprintf("%.4f", (comp$trend[48] / comp$trend[peak_i] - 1) * 100),
sprintf("%.4f", (comp$trend[48] / comp$trend[1] - 1) * 100))
)
| fact | value |
|---|---|
| trend peaked | 2025-03 |
| trend at peak | 3027.17 |
| trend now (Dec 2025) | 2791.17 |
| change since peak (%) | -7.7961 |
| change across whole window (%) | 48.8571 |
plot(d$month_start, d$trial_signups, type = "l", col = "#c9c9c9", lwd = 1.5,
xlab = "", ylab = "qualified trial signups",
main = "The trend, with the season taken out")
lines(d$month_start, comp$trend, col = "#e08a3a", lwd = 3.5)
abline(v = d$month_start[peak_i], lty = 2, col = "#e08a3a")
legend("topleft", bty = "n", lwd = c(1.5, 3.5), col = c("#c9c9c9", "#e08a3a"),
legend = c("signups as reported", "trend component"))
The trend turned over in March 2025 and has fallen 7.7961% since. Nine months of decline, and the December thread is about a month that behaved exactly as it always does.
Put the two magnitudes beside each other.
amplitude <- (max(seas) - min(seas)) / mean_trend * 100
decline <- abs(comp$trend[48] / comp$trend[peak_i] - 1) * 100
data.frame(
quantity = c("annual seasonal swing (points of trend)",
"the trend decline being looked for (points)",
"ratio"),
value = round(c(amplitude, decline, amplitude / decline), 4)
)
| quantity | value |
|---|---|
| annual seasonal swing (points of trend) | 71.5564 |
| the trend decline being looked for (points) | 7.7961 |
| ratio | 9.1785 |
The seasonal swing is 9.18 times the size of the signal anyone needed to see. Any month-over-month comparison puts a number roughly eight times too large in front of the one that mattered. This is not a failure of attention.
Comparing a month against the same month last year does hold the season roughly constant, so it is a genuine improvement on month-over-month. It is worth seeing exactly how far it gets.
yoy <- data.frame(
month = c("September", "December"),
y2023 = c(round((d$trial_signups[21] / d$trial_signups[9] - 1) * 100, 4), NA),
y2024 = c(round((d$trial_signups[33] / d$trial_signups[21] - 1) * 100, 4),
round((d$trial_signups[36] / d$trial_signups[24] - 1) * 100, 4)),
y2025 = c(round((d$trial_signups[45] / d$trial_signups[33] - 1) * 100, 4),
round((d$trial_signups[48] / d$trial_signups[36] - 1) * 100, 4))
)
yoy
| month | y2023 | y2024 | y2025 |
|---|---|---|---|
| September | 26.6108 | 1.1450 | -5.8113 |
| December | NA | 20.2835 | -19.8549 |
September’s year-over-year growth decays from 26.61% to 1.15% to -5.81%, which is the right story. But December’s year-over-year read is -19.85%, against a true trend decline of 7.80% — overstated by more than a factor of two, because a single-month comparison also carries both months’ noise draws. Directionally right, quantitatively unusable, and a board paper that says “down 19.8%” is wrong by the same margin as the December thread, in the other direction.
data.frame(
finding = c(
"December is seasonal, not a problem",
"The annual pattern is real and large",
"The trend turned over",
"Month-over-month cannot see it"
),
evidence = c(
sprintf("December sits %.4f%% below trend every year; the 2025 drop of %.4f%% is ordinary",
min(seas) / mean_trend * 100, (dec25 / nov25 - 1) * 100),
sprintf("seasonal strength %.6f; peak-to-trough %.4f points of trend",
seasonal_strength, amplitude),
sprintf("peaked %s, down %.4f%% since, after +%.4f%% across the window",
format(d$month_start[peak_i], "%Y-%m"), decline,
(comp$trend[48] / comp$trend[1] - 1) * 100),
sprintf("the seasonal swing is %.2fx the decline", amplitude / decline)
)
)
| finding | evidence |
|---|---|
| December is seasonal, not a problem | December sits -32.1722% below trend every year; the 2025 drop of -40.2299% is ordinary |
| The annual pattern is real and large | seasonal strength 0.908410; peak-to-trough 71.5564 points of trend |
| The trend turned over | peaked 2025-03, down 7.7961% since, after +48.8571% across the window |
| Month-over-month cannot see it | the seasonal swing is 9.18x the decline |
s.window = "periodic" assumes the seasonal
shape is stable. That is an assumption this document makes and
the data was built to satisfy. A business whose seasonality is genuinely
shifting needs a finite s.window, and the honest first step
there is to check whether the shape has moved rather than to fix it by
default.data.csv is written by the generation chunk above from
set.seed(20260825). Two independent checks live beside this
file:
check_canonical.R — R’s own functions called directly
on data.csv, outside this notebook, including a classical
decompose() as a second decomposition family.validate.py — an independent Python implementation
using numpy only, with the classical decomposition and
both strength measures written from scratch rather than imported, so the
arithmetic is genuinely separate.What those checks do and do not prove is written down in
VALIDATION.md, including the one place they cannot follow:
STL’s LOESS smoother is not reimplemented, so the trend component is
verified in shape, sign and magnitude rather than value-for-value.