Brief

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.

The data

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 ...

Structural integrity

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

What the room already believes

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.

The chart the thread should have opened with

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.

Splitting the series

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")

How much of this series is season?

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.

Which months, and by how much

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.

The thing nobody was looking at

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.

Why nobody saw it

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.

The same-month comparison gets closer, and is still wrong

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.

What to say

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

What this analysis does not establish

  • It does not say why. A decomposition names when and how much. September being the peak is a fact about the calendar in this data, not evidence that budget season causes signups. Attribution needs a different analysis and, ideally, an intervention with a date on it.
  • It does not say the decline continues. The trend component is a smoother, not a forecast, and its last points are the least certain in the series precisely because they have no future data on either side of them.
  • It does not separate two things that changed together. If a pricing change and a channel change both landed in spring 2025, the trend turn holds both, and nothing here can split them.
  • 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.

Reproducing this

data.csv is written by the generation chunk above from set.seed(20260825). Two independent checks live beside this file:

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.