, ,

Anomaly Detection on Metrics and Time Series for Infra Telemetry (Infra to Data Science Series, Part 21)

Static thresholds on seasonal infrastructure metrics measure the time of day, not trouble. Deseasonalise with STL, score the residual with a median and MAD based z, and reach for an isolation forest when several metrics move together.

Infra to Data Science Series · Part 21 of 26

A CPU alert wired to a fixed 85 percent line fired 140 times in one month on a single cluster and caught the incident that mattered at a precision of 0.05, so ninety five alerts in every hundred were the afternoon getting busy on schedule. Swap that line for a detector that first strips the daily and weekly shape out of the metric and the same month scores 0.63 precision at full recall on the same labels. Anomaly detection on infrastructure telemetry is mostly the work of deciding what normal looks like at this hour of this weekday, then measuring how far the latest point sits from it.

Who this is for: An infrastructure engineer or SRE who has exported a metric history and wants a detector that needs no labels. Terms on first use: seasonality is the repeating daily and weekly shape of a metric; deseasonalising removes that shape so what is left is the surprise; a residual is the metric minus its trend and season; median absolute deviation, MAD, is a spread measure that a few extreme points cannot inflate; contamination is the fraction of points an isolation forest is told to treat as anomalies.
Key takeaways: A static threshold on a seasonal metric measures the time of day, not trouble, so deseasonalise first. Decompose the series with STL, then flag on the residual using a median and MAD based score that outliers cannot drag around. Reach for an isolation forest when several metrics move together and a single line cannot separate normal from odd. Never grade a rare event detector by accuracy, one that flags nothing scores 97.5 percent here; read precision and recall instead.

Static Thresholds Break on Seasonal Metrics

Where the project stands: last part we tracked and registered a trained incident classifier so a model could be found and rolled back by name. This part turns the same exported telemetry into an anomaly detector that needs no labels at all, the kind of build a hiring manager reads as real infrastructure data work rather than a tutorial exercise. Measuring distance from a normal baseline is the same reflex as pointing observability and tracing at a running system, watching the signal that says something changed.

A static threshold is where everyone starts and where the false pages begin. Infrastructure metrics are seasonal: CPU climbs every weekday afternoon and falls every night, memory sawtooths with a nightly batch, request rate follows business hours. A fixed line cannot tell a Tuesday afternoon peak that happens every week from a runaway process, because both sit at the same absolute value. On the cluster below the raw CPU series has a standard deviation of 14.8 percentage points, and almost all of that spread is the daily season swinging across a 43 point range, not noise and not trouble. Grade the 85 percent rule against confirmed incidents and it flags 140 hours to catch 17 real ones, a precision of 0.05 and a recall of only 0.41, because the one incident that pulled CPU down instead of up never crossed the line at all. Raising the threshold to 95 percent, the reflex fix, only trades those false pages for missing the next real spike. Level was never the problem. Shape was.

# anomaly.py  tested with python 3.12, statsmodels 0.14.6, scikit-learn 1.7.2, pandas 2.3.3, numpy 2.2.6
import pandas as pd
from sklearn.metrics import precision_score, recall_score

# metrics.csv: hourly cpu, memory and load for one cluster, exported from monitoring
# the truth column is only for grading, the detector itself never sees a label
df = pd.read_csv('metrics.csv', index_col=0, parse_dates=True)
y = df['truth'].values
naive = (df['cpu'] > 85).astype(int).values
print('flagged', int(naive.sum()),
      'precision', round(precision_score(y, naive, zero_division=0), 2),
      'recall', round(recall_score(y, naive, zero_division=0), 2))
flagged 140 precision 0.05 recall 0.41

Deseasonalising a Metric With STL

STL, seasonal trend decomposition using LOESS, splits a series into three parts that add back together: a slow trend, a repeating season, and a residual that holds whatever the first two do not explain. That residual is the surprise, and it is what a detector should score. Because the Data Science Series already works through decomposition and backtesting in depth for a forecasting audience, I keep the mechanics short here and link the fuller treatment of time series forecasting and backtesting for the parts I skip. Every line below was run against python 3.12, statsmodels 0.14.6, scikit-learn 1.7.2, pandas 2.3.3 and numpy 2.2.6. Pass period as the number of points in one full seasonal cycle, here 24 for a day of hourly readings, and turn on the outlier resistant weighting so the injected spikes do not bend the fitted season around themselves.

from statsmodels.tsa.seasonal import STL

# period 24 = one day of hourly points; outlier resistant weighting keeps the two spikes
# from bending the season around themselves
res = STL(df['cpu'], period=24, robust=True).fit()
resid = res.resid                     # cpu minus its trend and daily season
print('resid std', round(resid.std(), 1),
      'peak resid', round(resid.abs().max(), 1))
resid std 6.2 peak resid 42.3

Removing the season pulls the spread down from 14.8 on the raw metric to a residual standard deviation of 6.2, and the runaway spike now stands 42 points clear of a residual that normally sits within about 6. Against the raw series that same spike hid inside the afternoon envelope; against the residual it is the tallest thing on the chart. One failure waits for anyone who reaches for STL on a bare array.

STL(df['cpu'].values, robust=True).fit()   # passing a raw numpy array, no period
ValueError: Unable to determine period from endog
Production gotcha: STL has to know the period. Hand it a bare numpy array with no time index and it cannot infer one, so it raises ValueError: Unable to determine period from endog. Pass period explicitly, or give STL a pandas Series whose index carries a frequency. Exported monitoring data usually loads its timestamps as plain strings, so set parse_dates on the read and an explicit period on the call every time, rather than trusting inference.

Residual Detector Using Median Absolute Deviation

A residual still needs a line, and that line should not be a plain standard deviation, because one large anomaly inflates the very spread you measure it against and hides the next one. Median absolute deviation is the median of the absolute gaps from the median, and a handful of extreme points cannot drag it. Scale it by 1.4826 and it estimates the same spread a standard deviation would on clean normal data, so a MAD based z-score reads on the familiar scale where 3 is notable and 5 is rare. Compute the median and the MAD in a centred rolling window so the baseline tracks slow drift, then flag any point whose score clears a threshold. This function is the reference artifact of this part, a short recipe that turns an exported metric into a labelled anomaly stream; keep it in the repo next to the export script.

def residual_anomalies(series, period, z_thresh=4.0, win=48):
    from statsmodels.tsa.seasonal import STL
    resid = STL(series, period=period, robust=True).fit().resid
    med = resid.rolling(win, center=True, min_periods=win // 4).median()
    mad = (resid - med).abs().rolling(win, center=True, min_periods=win // 4).median()
    score = (resid - med) / (1.4826 * mad)          # MAD based z-score
    return (score.abs() > z_thresh).astype(int)

flag = residual_anomalies(df['cpu'], period=24).values
print('flagged', int(flag.sum()),
      'precision', round(precision_score(y, flag, zero_division=0), 2),
      'recall', round(recall_score(y, flag, zero_division=0), 2))
flagged 27 precision 0.63 recall 1.0

On the same month this scores 0.63 precision at a recall of 1.0, so every real incident is caught for twenty seven flags against seventeen true ones. A threshold of 4 rather than 3 is a deliberate choice: 3 caught two more benign blips and cost precision, and on telemetry that pages a human, a quieter detector that still catches every real event is worth more than one that fires on every wobble. Move the threshold to fit what a false page costs your team, and hold recall at the level an incident review would accept. For a single seasonal metric this residual detector is the pick, and a raw isolation forest on the same one column, next, is the option to avoid.

One detail decides whether this detector works live or only in hindsight. A centred rolling window reads points on both sides of each timestamp, which is fine when you score an exported history but impossible in real time, where the future half of the window does not exist yet. For a live detector set center to false so the window trails the current point, accept that it reacts a little slower right after a shift in level, and warm it up on recent history before trusting its first scores. Scoring a backfill with a centred window and then shipping the same code untouched is a quiet way to report a recall you can never reproduce on live data.

Isolation Forest for Multivariate Telemetry

One metric rarely tells the whole story. A node draining shows up as CPU down, memory down and load down at the same time, a pattern no single line catches well. An isolation forest scores how few random splits it takes to isolate a point in the joint feature space, and points that separate in a handful of cuts are the odd ones. Feed it CPU, memory and load together and it reads the combination rather than each axis alone. Because a forest splits one feature at a time, it does not need the columns rescaled the way a distance based method would, one less preprocessing step than the residual path.

from sklearn.ensemble import IsolationForest

iso = IsolationForest(n_estimators=200, contamination=0.025, random_state=7)
multi = (iso.fit_predict(df[['cpu', 'mem', 'load']]) == -1).astype(int)
print('multi ', int(multi.sum()), round(precision_score(y, multi), 2), round(recall_score(y, multi), 2))

# the wrong way: the same forest on the single seasonal column
one = (IsolationForest(n_estimators=200, contamination=0.025, random_state=7)
       .fit_predict(df[['cpu']]) == -1).astype(int)
print('cpu   ', int(one.sum()), round(precision_score(y, one), 2), round(recall_score(y, one), 2))
multi  17 1.0 1.0
cpu    17 0.47 0.47

On the joint vector the forest flags seventeen points at a precision of 1.0 and a recall of 1.0. Read that perfect score with suspicion: this is clean generated data with two obvious injected events, and real telemetry never scores 1.0. What does hold on messier data is the gap between the two runs. Point the same forest at the raw CPU column alone and precision and recall both fall to 0.47, because on one seasonal axis the busiest legitimate afternoons look exactly as isolated as the incidents. An isolation forest earns its place across several metrics that move together, not as a drop in replacement for a threshold on one seasonal line, where deseasonalising first wins.

One argument does more damage than any other. contamination tells the forest what fraction of points to return as anomalies, and it acts as a hard prior, not a hint. Set it to 0.025 and almost exactly 2.5 percent of points come back flagged whether or not that many are truly odd, so a guess set too high manufactures false positives and one set too low buries real events. Rather than guess, score the raw decision function and pick the cut from labelled history, the same way the residual threshold was chosen. A second trap is quieter and costs nothing to avoid once you have seen it.

pred = iso.fit_predict(df[['cpu', 'mem', 'load']])
wrong = (pred == 1).astype(int)     # treating 1 as the anomaly, the binary label reflex
print('label 1 as anomaly, recall', round(recall_score(y, wrong, zero_division=0), 2))
label 1 as anomaly, recall 0.0

predict and fit_predict return minus one for an anomaly and plus one for a normal point, the opposite of the 1 equals bad habit from a binary label. Treat plus one as the anomaly and the detector silently reports a recall of 0.0 while looking like it ran clean. Flag on minus one, and check that the flagged count is small before you trust it.

flowchart LR
  M[Raw metric] --> D{One metric or many}
  D -->|one| S[STL deseasonalise]
  S --> R[Residual]
  R --> Z[Median and MAD score]
  Z --> F[Flag over threshold]
  D -->|many| I[Isolation forest on joint vector]
  I --> F
One path for a single seasonal metric, another for several that move together. Both end at the same flag, and both take their threshold from labelled history rather than a default.

Scoring a Detector Against Labels, Not Accuracy

Every number above is a precision or a recall, never an accuracy, and that is on purpose. At an anomaly rate of 2.5 percent a detector that flags nothing is right 97.5 percent of the time and useless, so accuracy rewards exactly the silence you are trying to break. Precision answers how many of your alerts were real, recall answers how many real events you caught, and the two trade against each other at every threshold. An on call team feels low precision as pager fatigue and low recall as a missed outage, so name the balance you want before you tune, not after. Grade against a window of confirmed incidents, count a flag anywhere inside a real event as a catch, and treat a lone flag in calm as a false positive. If you want a public trace to try this on before your own, the Numenta Anomaly Benchmark ships a real AWS CPU utilisation series with a documented cause.

Precision on the same labelled month, four detectorsshare of alerts that were real incidents, higher is better0.050.470.631.0static 85forest, cpu onlySTL residualforest, 3 metrics
The static line and the single metric forest sit at the bottom. STL residual scoring is the reliable pick for one seasonal metric; the three metric forest tops it here only because the generated data is clean.
DetectorFlaggedPrecisionRecall
Static 85 percent line1400.050.41
Isolation forest, cpu only170.470.47
STL residual, MAD z-score270.631.0
Isolation forest, three metrics171.01.0
War story: I ran a 3 sigma detector on raw CPU for a quarter and it paged the weekday on call almost every afternoon, roughly forty false pages a week, because the daily peak crossed three standard deviations of a flat baseline every single day. I had a change request open to raise the multiplier to 4 sigma, which would only have shifted the false pages to the busiest days and started missing the smaller real spikes. A colleague asked why the alerts clustered at 3pm, and that question ended it. Decomposing the metric with STL and scoring the residual dropped the false pages from about forty a week to two, and the first thing the new detector caught was a slow memory climb the old rule had never flagged because it never crossed an absolute line. A better multiplier was never the fix. Scoring the residual instead of the raw metric was.
SituationDetector to useWhy
One metric, clear daily or weekly seasonSTL residual, MAD z-scorescores surprise, not time of day
Several metrics that move togetherIsolation forest on the joint vectorreads the combination one line misses
Little history, no season yetMedian and MAD on a rolling windowoutlier resistant with no fitted model
A flat metric with a hard limita plain threshold is fineno season means no false peaks

Start With Residuals, Add a Forest When Metrics Move Together

None of this needs a model you have to train and serve. For a single seasonal metric, decompose with STL, score the residual with a median and MAD based z, and set the threshold from what a false page costs, that is the pick and it runs in a short function. When a failure shows across several metrics at once, put an isolation forest on the joint vector and choose its cut from labelled history rather than from a contamination guess. Steer clear of the two defaults that look reasonable and are not: a static line on a seasonal metric, and accuracy as the score for a rare event.

Do this on Monday: export a month of one metric from your own monitoring, run STL with the period set to your daily cycle, and plot the residual next to the raw series. You will watch the season leave and the real surprises stand up, and you will have the first honest anomaly stream off your own systems. Next part keeps the same exported telemetry and asks a different question of it, capacity forecasting, turning a month of history into a defensible estimate of when a cluster runs out of headroom.

Infra to Data Science Series · Part 21 of 26
« Previous: Part 20  |  Guide  |  Next: Part 22 »

References

About The Author


Discover more from Journal of Intelligent Infrastructure

Subscribe to get the latest posts sent to your email.

Leave a Reply

Your email address will not be published. Required fields are marked *

Architect’s Toolkit

About the Author

Dr. Pranay Jha is a Cloud and AI Consultant with 18+ years of experience in hybrid cloud, virtualization, and enterprise infrastructure transformation. He specializes in VMware technologies, multi-cloud strategy, and Generative AI solutions. He holds a PhD in Computer Applications with research focused on Cloud and AI, has published multiple research papers, and has been a VMware vExpert since 2016 and a VMUG Community Leader.

Discover more from Journal of Intelligent Infrastructure

Subscribe now to keep reading and get access to the full archive.

Continue reading