top of page

Anomaly Detection Is Just Forecasting in Disguise

9 minutes ago
5 min read

Building an attention filter that tells people where to look, instead of giving them more to look at



Most of us have a channel full of alerts we stopped reading months ago. They fired too often, about things that didn’t matter, so we muted them. The opposite problem is just as common: a dashboard full of data that nobody has time to review.

 

At Lander Analytics, we worked with a client monitoring daily activity across hundreds of geographic zones. Finding the unusual days was a needle-in-a-haystack problem, and checking every series by hand each morning would have been a monotonous exercise, with no guarantee that the important exceptions would get noticed. They needed a way to flag unusual activity automatically and give their team a short list worth investigating.


Anomaly detection is just forecasting wearing a disguise


You can't call a value surprising until you can say what you expected it to be. So the detector is a forecast with a band around it: run the forecast, draw an uncertainty band around that forecast, and flag anything that falls outside it. The alerting logic ends up being one line of comparison code, and nearly all the work lives in the forecast since forecasting is a problem people have been chipping away at for sixty years.


Three tiers of anomaly detection


The simplest approach is a trailing mean plus or minus k standard deviations, and it's where we start for every time series. For a lot of these geographic zones a full model is overkill, so we use exactly this, three standard deviations over a two-week window, with an absolute floor so a quiet zone doesn't start firing every time it wiggles by two counts. With {dplyr} and the {slider} package, the whole thing is a handful of lines:


library(dplyr)

library(slider)


scored <- activity |>

    group_by(zone) |>

    arrange(date) |>

    mutate(

        expected = lag(slide_index_dbl(count, date, mean, .before = 13)),

        spread   = lag(slide_index_dbl(count, date, sd,   .before = 13)),

        band     = pmax(3 * spread, 5),            # absolute floor

        upper    = expected + band,

        lower    = pmax(expected - band, 0),       # counts can't go negative

        flagged  = count > upper | count < lower

    ) |>

    ungroup()


The arguments to lag() need to be set carefully. If today's value is allowed to sit inside the window that defines "normal," today can never look abnormal, and that's a bug I've seen written more than once because it is easy to miss. Once the code is right this calculation is cheap, it's explainable in a single sentence to a nervous stakeholder and it is hard to beat. I'd start here on almost any new series.


This tier struggles when the process has a cyclical pattern. A rolling mean will call every Monday an anomaly forever, because the weekly cadence of human activity shows up in nearly every real dataset. That's where the second tier comes in: exponential smoothing, ARIMA, Prophet, the classical time-series toolkit that knows what a season is and how to properly account for it. The {fable} package fits these models for each series in seconds and hands back the prediction interval directly, no extra work required:


library(fable)


activity |>

    as_tsibble(key = zone, index = date) |>

    model(ets = ETS(count), arima = ARIMA(count)) |>

    forecast(h = 1) |>

    hilo(level = 80) # or 99 depending on the use case


The third tier is the one that would have been tough a few years ago. We use Amazon's Chronos, a transformer pretrained on an enormous corpus of time series, and we run it zero-shot, which means no training pipeline, no per-series tuning and it still produces calibrated quantile forecasts out of the box. Getting that quality used to mean a modeling effort for every single series, and now the marginal cost of monitoring one more stream has fallen substantially.


A lot of the places running this kind of monitoring can't call out to an external API at 2 a.m. and hope it's up; think banks, hospitals, anyone in a regulated industry. That’s why we ship the model weights inside the container image itself and turn off network access at inference time, so the whole thing runs fully offline, with reproducible builds and nothing depending on some model host staying up.


Together, the three tiers read every series every day and hand back a short list worth checking. They also run two-sided, so an unusually quiet day fires just like a busy one. Quiet days are often the interesting ones. An outage or a feed that died upstream can affect operations harder than any spike.


Lessons from prod


Statistics guarantee false positives. An 80% prediction interval puts one in five perfectly normal days outside the band by design. Multiply across four hundred series and suddenly you have alert fatigue.


Most of what looks like an anomaly is actually a data engineering problem. A missing day silently becomes a zero, and a zero looks exactly like the interesting kind of quiet. Timezone misalignment in how you bucket days can shift counts across midnight and bias the very day you're scoring, and you won't notice unless you go looking.


Cold start is a real constraint. We require several weeks of history before we'll score a series, and we tell users "not enough data yet" rather than let the model emit something that looks confident but isn't. "No anomaly" and "no data" have to be visibly different states, or people stop trusting either one.


Being anomalous isn't the same as being important. The model can tell you something is statistically surprising, but whether it's operationally meaningful is a judgment only someone with domain context can make. A system like this should hand someone a shortlist with the evidence attached and enable them to make the business decision.


You can't measure precision without labels. Real anomalies are rare and rarely labeled, so the accuracy metrics everyone wants don't apply. We validate by injecting synthetic spikes and confirming they fire alerts, and then leaning on what users tell us after the fact when something slipped through or shouldn't have alerted at all.


The principles I keep coming back to

Start with a rolling z-score and only move to ARIMA or Chronos when it loses, which happens less often than you’d think. Run everything two-sided, since a quiet day can matter as much as a spike. Check history length, gaps, and duplicates before scoring anything, because most bad alerts trace back to bad inputs.


The model reports that a series landed outside its band. The person looking at it decides whether that matters, so show them why it fired. We marked every alert on the actual-versus-expected chart and colored the map by alert state. If people can’t see why something fired, they stop trusting the alerts, and you’re back to the muted channel problem.


At Lander Analytics we build these end to end: the pipeline that guards the inputs, the models that make the forecast, and the interface that earns someone's trust in the flag.



Jared P. Lander

Founder and Chief Data Scientist

Lander Analytics



Subscribe to our Substack and below to our monthly emails for practical AI strategies for your organization: what to build, what to avoid, and how to make systems reliable in the real world.


Work with us: If you want help identifying the right first workflow, building a permissioned knowledge base, or training your team to ship responsibly, reach out at info@landeranalytics.com.


About the author: Jared P. Lander is Chief Data Scientist and founder of Lander Analytics, where he helps organizations build practical, measurable AI workflows grounded in strong data foundations.

Get our latest blog posts—delivered monthly!

  • X
  • LinkedIn - White Circle
  • Bluesky
  • Untitled design (53)
  • YouTube - White Circle

© 2026 Lander Analytics

bottom of page