Standard Trend Test
Executive Summary

Executive Summary

Whether Monthly Active Users is really trending or just noisy

Observations
36
Trend Verdict
Rising
Sen Slope
22.6118
Slope Units
per month
MK p-value
< 0.0001
Total Change
776.825
Lag-1 Autocorrelation
-0.176
Autocorrelation Caution
none
Verdict: rising. Monthly Active Users shows a statistically significant upward trend over Month (Mann-Kendall p < 0.0001). Sen's slope puts the pace at 22.6118 per month (95% CI 17.8599 to 27.6786), an implied total change of 776.825 (61.6% of the starting level) across the window.
Suggested Interpretation

The short answer

Monthly Active Users is genuinely trending upward at a median pace of 22.6118 users per month (95% CI: 17.8599 to 27.6786). The Mann-Kendall p-value of < 0.0001 confirms this is far more than noise. Over the full 36-month window, the implied total change is 776.825 users, representing 61.6% growth relative to the starting level.

The detail

Verdict: rising (Mann-Kendall p < 0.0001). Sen's slope: 22.6118 per month, with a 95% confidence interval of 17.8599 to 27.6786 — the interval excludes zero, confirming the upward direction. Lag-1 autocorrelation is -0.176 with no caution flag, meaning the independence assumption holds. Total implied change across the observed window is 776.825 users.

What this can't tell you

The analysis measures the central trend, not volatility or month-to-month variation. A -0.176 autocorrelation, though not flagged as problematic, indicates slight negative correlation between consecutive months — this does not invalidate the trend but reflects the data's internal structure.

Overview

Analysis Overview

A Mann-Kendall trend test with Sen's slope on Monthly Active Users over 36 time points.

N Points36
VerdictRising
Sen Slope22.6118
P Value4.4052e-10
Suggested Interpretation

The short answer

Monthly Active Users shows a statistically significant upward trend over the 36-month window. The Mann-Kendall test, which ranks every pair of time points to detect monotonic movement, yields a p-value of 4.4052e-10 — far stronger than chance would produce. Outliers or skew cannot distort this rank-based result.

The detail

The analysis tested 36 ordered observations (2023-01-01 to 2025-12-01) using Mann-Kendall, a test that votes on whether each later time point is higher or lower than earlier ones. With p = 4.4052e-10, the upward signal is unambiguous. Sen's slope — the median of all 630 pairwise slopes — quantifies the trend at 22.6118 users per month. The method assumes independence; a lag-1 autocorrelation check returned -0.176, showing no problematic serial correlation that would inflate the test statistic.

What this can't tell you

The test detects monotonic trend, not acceleration or deceleration within the window. Forecasting beyond 2025-12-01 requires additional assumptions about future patterns. The rank-based design is robust to outliers but does not identify which months drove the pattern.

Data Preparation

Data Quality

How the raw rows became an ordered time series.

Initial Rows36
Final Rows36
N Dropped0
N Duplicates Collapsed0
Time AxisMonth
Suggested Interpretation

The short answer

All 36 rows loaded successfully with no missing values, duplicates, or time-axis ambiguities. The 'Month' column was read as dates and the series was sorted chronologically, yielding a clean 36-point time series ready for analysis.

The detail

Initial rows: 36. Final rows: 36. Rows dropped: 0. Duplicate time points collapsed: 0. The 'Month' column was parsed as dates spanning 2023-01-01 to 2025-12-01 and sorted in chronological order. No pairwise slopes were sampled—all 630 pairs were computed. The time axis is the 'Month' variable.

What this can't tell you

This step does not assess whether the data-collection process itself introduced bias or whether the monthly frequency is appropriate for the underlying user behavior.

Visualization

Series with Sen Trend Line

Monthly Active Users over Month, with the fitted Sen slope overlaid.

Suggested Interpretation

The short answer

The chart shows Monthly Active Users (jagged line) climbing against the fitted Sen trend line (straight line rising at 22.6118 per month). The series oscillates around the trend but works its way upward faster than noise alone would explain, consistent with the p < 0.0001 verdict.

The detail

The actual series begins at 1,213 in January 2023 and reaches 1,844 by December 2025 (the final reported point). The Sen trend line rises 22.6118 per month. Notable peaks include 2,278 in October 2025 and 2,033 in March 2025; notable dips include 1,126 in March 2023 and 1,730 in June 2025. Despite month-to-month volatility, the cumulative upward drift is statistically significant (p < 0.0001), indicating the trend outpaces random fluctuation.

What this can't tell you

The chart does not reveal whether the trend is accelerating, decelerating, or following a nonlinear path; it shows only the median slope across all pairwise comparisons.

Data Table

Mann-Kendall Test Detail

The S statistic, its tie-corrected variance, z, p, and tie accounting.

StatisticValueInterpretation
Mann-Kendall S459Concordant minus discordant pairs — positive means Monthly Active Users tends to rise over Month
Variance of S (tie-corrected)5,389Null-hypothesis variance of S, reduced for the 1 group(s) of tied values
z statistic (continuity-corrected)6.239S standardized by its null variance, with the +/-1 continuity correction
Two-sided p-value< 0.0001Below 0.05 — the upward trend is unlikely to be chance
Tied value groups1 (2 values)Repeated values reduce the usable ordering information
Time points tested36Distinct time points of Monthly Active Users after cleaning
Suggested Interpretation

The short answer

The Mann-Kendall test counted 459 more pairs where the later value is higher than pairs where it is lower. This imbalance is so large relative to chance (z = 6.239, p < 0.0001) that the upward trend is genuine, not random noise.

The detail

Mann-Kendall S statistic: 459 (concordant minus discordant pairs across 36 time points). Variance of S (tie-corrected): 5,389. The correction accounts for 1 group of tied values (2 values), which carry no ordering information. Continuity-corrected z: 6.239. Two-sided p-value: < 0.0001. Time points tested: 36. The S statistic is the net count of "later is higher" versus "later is lower" across every pair; a large positive value with a tiny p-value means the systematic upward tendency far exceeds what random ordering would produce.

What this can't tell you

The test does not quantify the magnitude of change per unit time—that is Sen's slope's role—nor does it indicate whether the trend is linear.

Data Table

Sen's Slope and Implied Change

How fast Monthly Active Users is moving, with its confidence interval.

StatisticValueInterpretation
Sen slope (per month)22.6118Median of the 630 pairwise slopes, in units of Monthly Active Users per month
95% confidence interval17.8599 to 27.6786Classical rank-based interval from the ordered pairwise slopes
Implied total change over the window776.825The Sen slope carried across the full window, in units of Monthly Active Users
Implied change as % of starting level61.6%Total change relative to the Sen trend line's starting level of 1,261.87
Observed window2023-01-01 to 2025-12-01 (about 34.4 months)The span of Month covered by the test
Suggested Interpretation

The short answer

Monthly Active Users is climbing at a median rate of 22.6118 per month. The 95% confidence interval (17.8599 to 27.6786) excludes zero, confirming the upward direction is robust across plausible slope estimates. Over the 34.4-month window, this pace yields a total change of 776.825 users, or 61.6% of the trend line's starting level of 1,261.87.

The detail

Sen's slope is the median of all 630 pairwise slopes between time points — a statistic immune to outliers. The point estimate is 22.6118 per month (units: Monthly Active Users per month). The classical rank-based 95% CI spans 17.8599 to 27.6786. Carried across the full window from 2023-01-01 to 2025-12-01 (about 34.4 months), the implied total change is 776.825 users, equivalent to 61.6% growth relative to the Sen trend line's starting level of 1,261.87.

What this can't tell you

Sen's slope assumes the trend is linear or monotonic; it does not detect acceleration or deceleration. The confidence interval reflects uncertainty in the slope estimate itself, not prediction uncertainty for future months outside the observed window.

Data Table

Methods and Checks

How the test was run, and whether its assumptions held.

CheckResultNotes
Trend testMann-Kendall, two-sided (z = 6.239, p < 0.0001)Nonparametric: uses only the ordering of the values, so outliers and skew do not distort it
Tie correctionApplied (1 tie group(s))Tied values carry no ordering information; the variance of S is reduced accordingly
Continuity correctionApplied (+/-1 on S)Improves the normal approximation for the discrete S statistic
Slope estimatorSen's slope: 22.6118 per monthMedian of pairwise slopes — robust to outliers, stated in the value's own units
Pairwise slopes usedAll 630 pairsThe complete pairwise slope set was computed — no sampling
Serial-correlation checkLag-1 autocorrelation of residuals = -0.176 (threshold 0.3) — clearResiduals look serially independent enough for the standard test
Suggested Interpretation

The short answer

The Mann-Kendall test was applied with tie and continuity corrections; Sen's slope was computed from all 630 pairwise slopes. Lag-1 autocorrelation of residuals is -0.176, well below the 0.3 threshold, so the independence assumption holds and the p-value is reliable.

The detail

Trend test: Mann-Kendall, two-sided (z = 6.239, p < 0.0001). Nonparametric method using only the ordering of values, so outliers and skew do not distort it. Tie correction: applied (1 tie group of 2 values; variance of S reduced accordingly). Continuity correction: applied (+/-1 on S) to improve the normal approximation for the discrete statistic. Slope estimator: Sen's slope, 22.6118 per month (median of pairwise slopes, robust to outliers). Pairwise slopes: all 630 pairs computed, no sampling. Serial-correlation check: lag-1 autocorrelation of detrended residuals = -0.176 (threshold 0.3). Result: clear of serial dependence; the Mann-Kendall p-value can be read at face value.

What this can't tell you

These methods do not establish causation or explain why the trend exists. Positive autocorrelation above 0.3 would inflate trend-test confidence; this analysis cleared that threshold.

Methodology

Methodology

Statistical methodology and diagnostics for Trend Test — Is It Really Rising?

Statistical Method

Trend Test — Is It Really Rising?

Standard-library analysis: the Mann-Kendall trend test with Sen's slope — the classical nonparametric pair for deciding whether an ordered series has a real monotonic trend or is just noise. Computes the tie-corrected Mann-Kendall statistic with a two-sided p-value, Sen's slope (the median pairwise slope) with its classical confidence interval, the implied total change over the window, and an honest serial-correlation caution when autocorrelation would inflate the test's confidence. Works on any dataset: map a date (or sortable index) column and the numeric value to test.

Data
N = 36 observations
Assumptions
  • Observations are recorded at meaningful, orderable time points (dates or a sortable index)
  • The value column is numeric or cleanly convertible
  • Observations are approximately independent — the analysis checks lag-1 autocorrelation and warns when this fails
Limitations
  • Tests for a monotonic trend only — a rise-then-fall pattern can test as no trend
  • Positive serial correlation inflates the test's confidence; the caution flags it but does not correct for it
  • The percent-change figure depends on the trend line's starting level and is not reported when that level is near zero
Software & Citation
MCP Analytics · mcpanalytics.ai
Code Appendix

Analysis Code

Complete R source code for this analysis

Trend Test — Is It Really Rising?

Tests whether an ordered series shows a real monotonic trend or just noise: the classical Mann-Kendall trend test (tie-corrected variance, continuity-corrected normal approximation) paired with Sen's slope — the median of all pairwise slopes — with its classical rank-based confidence interval, plus an honest serial-correlation caution.

Why This Method?

Mann-Kendall + Sen's slope is the standard nonparametric trend pair: it needs no normality assumption, shrugs off outliers, and separates "the line drifts up" from "the noise happened to end high". Sen's slope then states the trend in the value's own units per period.

What This Analysis Covers

  • Mann-Kendall S, tie-corrected variance, z, and two-sided p
  • Sen's slope with its classical confidence interval and implied change
  • The series plotted with the Sen trend line overlaid
  • A lag-1 autocorrelation check that warns when the test's confidence

is inflated by serial correlation

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: Read the time axis — real dates first, else a sortable index

raw_seq <- df$date
  non_blank <- !(is.na(raw_seq) | !nzchar(trimws(as.character(raw_seq))))
  n_nonblank <- sum(non_blank)
  d <- parse_dates_robust(raw_seq)
  frac_dates <- if (n_nonblank > 0) sum(!is.na(d)) / n_nonblank else 0
  numseq <- suppressWarnings(as.numeric(as.character(raw_seq)))
  frac_num <- if (n_nonblank > 0) sum(!is.na(numseq)) / n_nonblank else 0

  if (frac_dates >= 0.8) {
    t_raw <- as.numeric(d)
    seq_kind <- "date"
  } else if (frac_num >= 0.95 && n_nonblank > 0) {
    t_raw <- numseq
    seq_kind <- "numeric"
  } else {
    stop(sprintf("The &#x27;%s' column could not be read as dates or as a sortable numeric index — a trend test needs an orderable time axis.",
                 date_name))
  }

Step 2: Coerce the value (95% rule) and drop unusable rows

mv <- df$value
  if (!is.numeric(mv)) {
    conv <- suppressWarnings(as.numeric(as.character(mv)))
    n_orig <- sum(!is.na(mv) & nzchar(as.character(mv)))
    if (n_orig > 0 && sum(!is.na(conv)) >= 0.95 * n_orig) {
      mv <- conv
    } else {
      stop(sprintf("The &#x27;%s' column is not numeric — a trend test needs a numeric value to track.",
                   value_name))
    }
  }
  mv <- as.numeric(mv)
  keep <- !is.na(mv) & !is.na(t_raw)
  n_dropped <- sum(!keep)
  t_kept <- t_raw[keep]
  x_kept <- mv[keep]

Step 3: Sort by time and collapse duplicate time points (mean)

ord <- order(t_kept)
  t_kept <- t_kept[ord]
  x_kept <- x_kept[ord]
  n_dup_collapsed <- 0L
  if (anyDuplicated(t_kept)) {
    n_before <- length(t_kept)
    agg <- tapply(x_kept, t_kept, mean)
    t_num <- as.numeric(names(agg))
    x <- as.numeric(agg)
    o2 <- order(t_num)
    t_num <- t_num[o2]; x <- x[o2]
    n_dup_collapsed <- n_before - length(x)
  } else {
    t_num <- t_kept
    x <- x_kept
  }
  n_points <- length(x)
  final_rows <- n_points
  rows_removed <- initial_rows - final_rows

  if (n_points < 8) {
    stop(sprintf("Only %d usable %s of &#x27;%s' over '%s' — a trend test needs at least 8 time points.",
                 n_points, pts(n_points), value_name, date_name))
  }
  if (isTRUE(max(x) == min(x))) {
    stop(sprintf("&#x27;%s' has no variation — every one of the %d values equals %s, so there is no trend to test.",
                 value_name, n_points, fmt_num(x[1], 4)))
  }

Step 4: Time-range prose + inferred reporting period

gaps <- diff(t_num)
  step <- stats::median(gaps)
  if (seq_kind == "date") {
    seq_range_text <- sprintf("%s to %s",
                              format(as.Date(min(t_num), origin = "1970-01-01"), "%Y-%m-%d"),
                              format(as.Date(max(t_num), origin = "1970-01-01"), "%Y-%m-%d"))
    period_word <- if (step <= 1.5) "day"
      else if (step >= 6 && step <= 8) "week"
      else if (step >= 13 && step <= 15) "fortnight"
      else if (step >= 28 && step <= 31.5) "month"
      else if (step >= 84 && step <= 95) "quarter"
      else if (step >= 350 && step <= 380) "year"
      else sprintf("%s-day period", fmt_num(step, 1))
  } else {
    seq_range_text <- sprintf("%s to %s", fmt_num(min(t_num)), fmt_num(max(t_num)))
    period_word <- "step"
  }

Step 5: Mann-Kendall S with tie-corrected variance (hand-rolled)

S = sum over all i<j of sign(x_j - x_i); the inner loop is vectorized so large series stay fast. Ties in the VALUES reduce Var(S) through the classical correction term.

S <- 0
  for (i in seq_len(n_points - 1)) {
    S <- S + sum(sign(x[(i + 1):n_points] - x[i]))
  }
  tie_tab <- table(x)
  tie_sizes <- as.numeric(tie_tab[tie_tab > 1])
  n_tie_groups <- length(tie_sizes)
  n_tied_values <- if (n_tie_groups > 0) sum(tie_sizes) else 0L
  varS <- (n_points * (n_points - 1) * (2 * n_points + 5) -
             sum(tie_sizes * (tie_sizes - 1) * (2 * tie_sizes + 5))) / 18
  if (!is.finite(varS) || varS <= 0) {
    stop(sprintf("&#x27;%s' has effectively no variation (all %d values are tied) — there is no trend to test.",
                 value_name, n_points))
  }

Continuity-corrected normal approximation, two-sided p

z_stat <- if (S > 0) (S - 1) / sqrt(varS) else if (S < 0) (S + 1) / sqrt(varS) else 0
  p_value <- 2 * stats::pnorm(-abs(z_stat))

Step 6: Sen's slope — median of pairwise slopes (per t-unit)

All N = n(n-1)/2 pairs when N <= 5000; otherwise a seeded random sample of 5000 pairs (set.seed(42)) — and the prose says so.

n_pairs <- n_points * (n_points - 1) / 2
  slopes_sampled <- n_pairs > 5000
  if (!slopes_sampled) {
    ii <- rep(seq_len(n_points - 1), times = (n_points - 1):1)
    jj <- unlist(lapply(seq_len(n_points - 1), function(i) (i + 1):n_points))
  } else {
    set.seed(42)
    ii <- integer(0); jj <- integer(0)
    while (length(ii) < 5000) {
      a <- sample.int(n_points, 8000, replace = TRUE)
      b <- sample.int(n_points, 8000, replace = TRUE)
      ok <- a < b
      ii <- c(ii, a[ok]); jj <- c(jj, b[ok])
      if (length(ii) >= 5000) { ii <- ii[1:5000]; jj <- jj[1:5000] }
    }
  }
  slopes <- (x[jj] - x[ii]) / (t_num[jj] - t_num[ii])
  slopes <- slopes[is.finite(slopes)]
  slopes <- sort(slopes)
  n_pairs_used <- length(slopes)
  sen_slope_t <- stats::median(slopes)

Classical rank-based CI for Sen's slope: C = z_{0.975} * sqrt(Var(S)); the CI bounds sit at ranks (N - C)/2 and (N + C)/2 + 1 of the ordered pairwise slopes. When the pairs were sampled, the same ranks are taken as quantiles of the sampled slope distribution.

C_alpha <- stats::qnorm(0.975) * sqrt(varS)
  rank_lo <- (n_pairs - C_alpha) / 2
  rank_hi <- (n_pairs + C_alpha) / 2 + 1
  p_lo <- min(max(rank_lo / n_pairs, 1 / n_pairs_used), 1)
  p_hi <- min(max(rank_hi / n_pairs, 1 / n_pairs_used), 1)
  ci_t <- as.numeric(stats::quantile(slopes, probs = c(p_lo, p_hi),
                                     type = 1, names = FALSE))
  ci_lo_t <- ci_t[1]
  ci_hi_t <- ci_t[2]

Express the slope per reporting period (per t-unit x median spacing)

sen_slope_period <- sen_slope_t * step
  ci_lo_period <- ci_lo_t * step
  ci_hi_period <- ci_hi_t * step

Step 7: Implied change over the observed window

Sen intercept (Conover): median of (x_i - slope t_i); the trend line is intercept + slope t.

intercept <- stats::median(x - sen_slope_t * t_num)
  span_t <- max(t_num) - min(t_num)
  total_change <- sen_slope_t * span_t
  start_level <- intercept + sen_slope_t * min(t_num)
  pct_change <- if (is.finite(start_level) && abs(start_level) > 1e-12) {
    100 * total_change / abs(start_level)
  } else NA_real_

Step 8: Serial-correlation caution — lag-1 autocorrelation of the

residuals from a simple linear fit. Positive autocorrelation makes the Mann-Kendall variance too small, so the p-value overstates confidence.

fit <- stats::lm(x ~ t_num)
  e <- stats::residuals(fit)
  em <- mean(e)
  r1 <- sum((e[-n_points] - em) * (e[-1] - em)) / sum((e - em)^2)
  autocorr_flag <- is.finite(r1) && abs(r1) > 0.3
  autocorr_caution <- if (!autocorr_flag) {
    sprintf("Lag-1 autocorrelation of the detrended residuals is %s — low enough that the Mann-Kendall p-value can be read at face value.",
            fmt_num(r1, 2))
  } else if (r1 > 0) {
    sprintf("WARNING: the detrended residuals are strongly serially correlated(lag-1 autocorrelation %s, above the 0.3 threshold). Positive autocorrelation makes consecutive %s move together, which shrinks the effective sample size and INFLATES the trend test&#x27;s confidence — the true p-value is larger than the %s reported here. Treat the significance verdict as optimistic.",
            fmt_num(r1, 2), pts(2), fmt_p(p_value))
  } else {
    sprintf("WARNING: the detrended residuals show strong negative serial correlation(lag-1 autocorrelation %s, beyond the 0.3 threshold). The normal approximation behind the Mann-Kendall p-value assumes independent observations, so the reported p-value should be treated with caution.",
            fmt_num(r1, 2))
  }

Step 9: Verdict

significant <- is.finite(p_value) && p_value < 0.05
  trend_up <- S > 0
  verdict <- if (!significant) "No significant trend"
             else if (trend_up) "Rising" else "Falling"
  direction_word <- if (trend_up) "upward" else if (S < 0) "downward" else "flat"

Step 10: Chart dataset — actual series + the Sen trend line

(<= 1000 points per series; always keep the first and last point)

if (n_points <= 1000) {
    keep_idx <- seq_len(n_points)
  } else {
    set.seed(42)
    keep_idx <- sort(unique(c(1L, n_points,
                              sample(2:(n_points - 1), 998))))
  }
  period_chr <- if (seq_kind == "date") {
    format(as.Date(t_num[keep_idx], origin = "1970-01-01"), "%Y-%m-%d")
  } else {
    as.character(t_num[keep_idx])
  }
  fitted_line <- intercept + sen_slope_t * t_num[keep_idx]
  trend_series_df <- rbind(
    data.frame(period = period_chr,
               value = round(x[keep_idx], 4),
               series = "Actual",
               stringsAsFactors = FALSE),
    data.frame(period = period_chr,
               value = round(fitted_line, 4),
               series = "Sen trend",
               stringsAsFactors = FALSE)
  )
  rownames(trend_series_df) <- NULL

Step 11: Test-detail table

mk_test_df <- data.frame(
    statistic = c("Mann-Kendall S", "Variance of S(tie-corrected)",
                  "z statistic(continuity-corrected)", "Two-sided p-value",
                  "Tied value groups", "Time points tested"),
    value = c(fmt_num(S, 0), fmt_num(varS, 1), fmt_num(z_stat, 3),
              fmt_p(p_value), sprintf("%d(%d values)", n_tie_groups, n_tied_values),
              fmt_num(n_points, 0)),
    interpretation = c(
      sprintf("Concordant minus discordant pairs — positive means %s tends to rise over %s", value_name, date_name),
      sprintf("Null-hypothesis variance of S, reduced for the %d group(s) of tied values", n_tie_groups),
      "S standardized by its null variance, with the +/-1 continuity correction",
      if (significant) sprintf("Below 0.05 — the %s trend is unlikely to be chance", direction_word)
      else "At or above 0.05 — the ordering is consistent with noise",
      if (n_tie_groups > 0) "Repeated values reduce the usable ordering information"
      else "No repeated values — the full ordering information was used",
      sprintf("Distinct time points of %s after cleaning", value_name)
    ),
    stringsAsFactors = FALSE
  )
  rownames(mk_test_df) <- NULL

Step 12: Slope table

window_desc <- sprintf("%s(%s)", seq_range_text,
                         if (seq_kind == "date") sprintf("about %s %ss", fmt_num(span_t / step, 1), period_word)
                         else sprintf("%s %ss", fmt_num(span_t / max(step, 1e-12), 1), period_word))
  sen_slope_df <- data.frame(
    statistic = c(sprintf("Sen slope(per %s)", period_word),
                  "95% confidence interval",
                  "Implied total change over the window",
                  "Implied change as % of starting level",
                  "Observed window"),
    value = c(fmt_num(sen_slope_period, 4),
              sprintf("%s to %s", fmt_num(ci_lo_period, 4), fmt_num(ci_hi_period, 4)),
              fmt_num(total_change, 3),
              if (is.na(pct_change)) "n/a(starting level is ~0)"
              else sprintf("%s%%", fmt_num(pct_change, 1)),
              window_desc),
    interpretation = c(
      sprintf("Median of the %s pairwise slopes, in units of %s per %s%s",
              format(n_pairs_used, big.mark = ","), value_name, period_word,
              if (slopes_sampled) sprintf(" (a seeded random sample of the %s possible pairs)",
                                          format(n_pairs, big.mark = ",")) else ""),
      "Classical rank-based interval from the ordered pairwise slopes",
      sprintf("The Sen slope carried across the full window, in units of %s", value_name),
      if (is.na(pct_change)) "The trend-line starting level is approximately zero, so a percentage is not meaningful"
      else sprintf("Total change relative to the Sen trend line&#x27;s starting level of %s", fmt_num(start_level, 3)),
      sprintf("The span of %s covered by the test", date_name)
    ),
    stringsAsFactors = FALSE
  )
  rownames(sen_slope_df) <- NULL

Step 13: Methods / checks table

methods_df <- data.frame(
    check = c("Trend test", "Tie correction", "Continuity correction",
              "Slope estimator", "Pairwise slopes used",
              "Serial-correlation check"),
    result = c(sprintf("Mann-Kendall, two-sided(z = %s, p %s)", fmt_num(z_stat, 3),
                       if (p_value < 0.0001) fmt_p(p_value) else paste0("= ", fmt_p(p_value))),
               if (n_tie_groups > 0) sprintf("Applied(%d tie group(s))", n_tie_groups) else "Not needed(no ties)",
               "Applied(+/-1 on S)",
               sprintf("Sen&#x27;s slope: %s per %s", fmt_num(sen_slope_period, 4), period_word),
               if (slopes_sampled) sprintf("%s of %s pairs(seeded sample)",
                                           format(n_pairs_used, big.mark = ","),
                                           format(n_pairs, big.mark = ","))
               else sprintf("All %s pairs", format(n_pairs_used, big.mark = ",")),
               sprintf("Lag-1 autocorrelation of residuals = %s(threshold 0.3) — %s",
                       fmt_num(r1, 3), if (autocorr_flag) "CAUTION FIRED" else "clear")),
    notes = c("Nonparametric: uses only the ordering of the values, so outliers and skew do not distort it",
              "Tied values carry no ordering information; the variance of S is reduced accordingly",
              "Improves the normal approximation for the discrete S statistic",
              "Median of pairwise slopes — robust to outliers, stated in the value&#x27;s own units",
              if (slopes_sampled) "With this many points the full pair set is large; a seeded sample of 5,000 pairs estimates the same median"
              else "The complete pairwise slope set was computed — no sampling",
              if (autocorr_flag) "See the caution: serial correlation inflates the trend test&#x27;s confidence"
              else "Residuals look serially independent enough for the standard test"),
    stringsAsFactors = FALSE
  )
  rownames(methods_df) <- NULL

  metrics <- list(
    `Observations`             = n_points,
    `Trend Verdict`            = verdict,
    `Sen Slope`                = round(sen_slope_period, 4),
    `Slope Units`              = sprintf("per %s", period_word),
    `MK p-value`               = fmt_p(p_value),
    `Total Change`             = round(total_change, 3),
    `Lag-1 Autocorrelation`    = round(r1, 3),
    `Autocorrelation Caution`  = if (autocorr_flag) "yes — read the warning" else "none"
  )

  json_output <- list(
    answer = paste0(
      "Mann-Kendall trend test on ", n_points, " time ", pts(n_points), " of ",
      value_name, " over ", date_name, " (", seq_range_text, "): ",
      if (significant) {
        paste0("a statistically significant ", direction_word, " trend(S = ",
               fmt_num(S, 0), ", z = ", fmt_num(z_stat, 2), ", p ",
               if (p_value < 0.0001) fmt_p(p_value) else paste0("= ", fmt_p(p_value)), "). ")
      } else {
        paste0("no statistically significant monotonic trend(S = ", fmt_num(S, 0),
               ", z = ", fmt_num(z_stat, 2), ", p = ", fmt_p(p_value),
               ") — the movement is consistent with noise. ")
      },
      "Sen&#x27;s slope estimates ", fmt_num(sen_slope_period, 4), " per ", period_word,
      " (95% CI ", fmt_num(ci_lo_period, 4), " to ", fmt_num(ci_hi_period, 4),
      "), implying a total change of ", fmt_num(total_change, 3),
      if (!is.na(pct_change)) paste0(" (", fmt_num(pct_change, 1), "% of the starting level)") else "",
      " across the window.",
      if (autocorr_flag) paste0(" CAUTION: lag-1 autocorrelation of the residuals is ",
                                fmt_num(r1, 2),
                                " — serial correlation inflates the test&#x27;s confidence, so the p-value is optimistic.") else ""
    ),
    cards = lapply(
      c("tldr", "overview", "preprocessing", "trend_chart",
        "mk_test", "sen_slope", "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,
    seq_kind = seq_kind, seq_range_text = seq_range_text,
    n_points = n_points, n_dropped = n_dropped, n_dup_collapsed = n_dup_collapsed,
    period_word = period_word, step = step,
    S = S, varS = varS, z_stat = z_stat, p_value = p_value,
    n_tie_groups = n_tie_groups, n_tied_values = n_tied_values,
    sen_slope_t = sen_slope_t, sen_slope_period = sen_slope_period,
    ci_lo_period = ci_lo_period, ci_hi_period = ci_hi_period,
    n_pairs = n_pairs, n_pairs_used = n_pairs_used, slopes_sampled = slopes_sampled,
    intercept = intercept, start_level = start_level,
    total_change = total_change, pct_change = pct_change,
    r1 = r1, autocorr_flag = autocorr_flag, autocorr_caution = autocorr_caution,
    trend_up = trend_up, significant = significant,
    verdict = verdict, direction_word = direction_word,
    trend_series_df = trend_series_df, mk_test_df = mk_test_df,
    sen_slope_df = sen_slope_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