TH2.3 series_decompose_anomalies()

4-5 hours · Module 2 · Free
Operational Objective
Manually scanning time series arrays for anomalies does not scale. KQL's series_decompose_anomalies() function applies statistical anomaly detection to any time series — identifying data points that deviate significantly from the expected pattern, accounting for trend and seasonality. This is the operator that makes behavioral anomaly detection practical at scale in hunting campaigns.
Deliverable: The ability to apply series_decompose_anomalies() to M365 behavioral time series, interpret the anomaly scores, and tune sensitivity for different hunting scenarios.
⏱ Estimated completion: 30 minutes

The function that does the statistics for you

TH2.1 taught manual statistical outlier detection — calculating percentiles and z-scores yourself. series_decompose_anomalies() automates this at a more sophisticated level. It decomposes a time series into three components — baseline (expected value), seasonality (repeating patterns like weekday/weekend cycles), and residual (everything left over). Anomalies are data points where the residual exceeds a threshold.

The function handles non-stationary data (trends), periodic patterns (work hours, business cycles), and noise — without requiring you to model any of these manually.

Basic anomaly detection

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// Detect anomalous days in per-user sign-in volume
SigninLogs
| where TimeGenerated > ago(30d)
| where ResultType == 0
| make-series SignInCount = count()
    on TimeGenerated
    from ago(30d) to now()
    step 1d
    by UserPrincipalName
| extend (anomalies, score, baseline) =
    series_decompose_anomalies(SignInCount, 1.5)
// anomalies: array of -1 (low anomaly), 0 (normal), 1 (high anomaly)
// score: array of anomaly scores (higher = more anomalous)
// baseline: array of expected values
// 1.5 = sensitivity threshold (lower = more sensitive, more results)
| mv-expand
    TimeGenerated to typeof(datetime),
    SignInCount to typeof(long),
    anomalies to typeof(int),
    score to typeof(double),
    baseline to typeof(double)
| where anomalies != 0
// Only anomalous data points
| project TimeGenerated, UserPrincipalName,
    SignInCount, baseline, score, anomalies
| sort by score desc
// Results: user-day combinations where sign-in volume was
// statistically anomalous relative to the user's own pattern
// score > 0 = unusually high volume
// score < 0 = unusually low volume (also potentially suspicious)

Interpreting the output

anomalies = 1 (positive anomaly): The value is significantly higher than expected. For sign-in counts: more sign-ins than the user’s pattern predicts. For download volumes: more downloads than usual. This is the primary hunting signal — increased activity may indicate compromise.

anomalies = -1 (negative anomaly): The value is significantly lower than expected. For sign-in counts: fewer sign-ins than usual. This can indicate: the user’s account has been taken over and the attacker is signing in from a different path (non-interactive instead of interactive), the user has been locked out, or the user is on leave and the account should be dormant but is being used. Negative anomalies are underused in hunting — they can be as informative as positive ones.

anomalies = 0: Normal. The value falls within the expected range given the baseline, trend, and seasonality. These data points require no investigation.

score: The anomaly score is the number of standard deviations the residual deviates from zero. Higher absolute scores indicate stronger anomalies. Use the score to rank results — investigate the highest scores first.

Tuning sensitivity

The threshold parameter (1.5 in the example above) controls how many standard deviations from the baseline a value must be to qualify as anomalous. The default is 1.5.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// Compare sensitivity levels
// Same data, different thresholds
let data = SigninLogs
| where TimeGenerated > ago(30d)
| where ResultType == 0
| make-series SignInCount = count()
    on TimeGenerated from ago(30d) to now() step 1d
    by UserPrincipalName;
data | extend (a1, s1, b1) = series_decompose_anomalies(SignInCount, 1.0)
| mv-expand a1 to typeof(int) | where a1 != 0
| summarize Threshold1_Anomalies = count();
// threshold 1.0 = aggressive (more anomalies, more FPs)
data | extend (a2, s2, b2) = series_decompose_anomalies(SignInCount, 1.5)
| mv-expand a2 to typeof(int) | where a2 != 0
| summarize Threshold15_Anomalies = count();
// threshold 1.5 = default (balanced)
data | extend (a3, s3, b3) = series_decompose_anomalies(SignInCount, 3.0)
| mv-expand a3 to typeof(int) | where a3 != 0
| summarize Threshold3_Anomalies = count();
// threshold 3.0 = conservative (fewer anomalies, fewer FPs)

Guidance: Start with the default 1.5. If results are too noisy (hundreds of anomalies), increase to 2.0 or 3.0. If results are empty, decrease to 1.0. Campaign modules specify the threshold they have found effective for each technique.

Applied example: detecting authentication volume spikes

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// Hunting application: find users with anomalous authentication bursts
// This is the TH4 authentication anomaly campaign in preview
SigninLogs
| where TimeGenerated > ago(30d)
| where ResultType == 0
| make-series SignInCount = count()
    on TimeGenerated
    from ago(30d) to now()
    step 1h
    by UserPrincipalName
// Hourly bins to catch intra-day bursts
| extend (anomalies, score, baseline) =
    series_decompose_anomalies(SignInCount, 2.0)
| mv-expand
    TimeGenerated to typeof(datetime),
    SignInCount to typeof(long),
    anomalies to typeof(int),
    score to typeof(double),
    baseline to typeof(double)
| where anomalies == 1
| where score > 5
// Only strong positive anomalies (score > 5)
| summarize
    AnomalousHours = count(),
    MaxScore = max(score),
    MaxSignIns = max(SignInCount),
    MaxBaseline = max(baseline)
    by UserPrincipalName
| where AnomalousHours >= 2
// Users with 2+ anomalous hours  sustained spike, not a single outlier
| sort by MaxScore desc
// These users had authentication bursts that significantly exceeded
//   their hourly baseline  AiTM token replay produces this pattern
SERIES_DECOMPOSE_ANOMALIES — HOW IT WORKSTIME SERIESfrom make-series[5,3,4,2,45,3,6...]DECOMPOSEbaseline: [4,3,4,3,4,3,4...]seasonality: [1,0,1,0,1,0,1...]residual: [0,0,-1,-1,40,0,1...]residual 40 >> thresholdANOMALY FLAGS[0, 0, 0, 0, 1, 0, 0...]Position 4 = ANOMALYINVESTIGATEmv-expand →where anomalies=1The function handles trend, seasonality, and noise automatically.You provide the time series and the threshold. It provides the anomaly flags.

Figure TH2.3 — How series_decompose_anomalies works. The function decomposes the time series into baseline, seasonality, and residual, then flags data points where the residual exceeds the sensitivity threshold.

Try it yourself

Exercise: Run anomaly detection on your sign-in data

Run the basic anomaly detection query (first query in this subsection) against your SigninLogs. How many user-day combinations are flagged as anomalous?

Examine the top 5 by score. For each: is the anomaly explainable (weekend work, holiday, user travel) or unexpected? This is the analysis step — the function identified the statistical anomalies, you determine whether they are security-relevant.

Then run with threshold 3.0 instead of 1.5. How many results survive the stricter threshold? The difference between 1.5 and 3.0 results is the sensitivity band you will tune per campaign.

⚠ Compliance Myth: "Anomaly detection replaces hunting — deploy it and you are covered"

The myth: series_decompose_anomalies() as a scheduled query provides automated hunting. Deploy it and the known-unknown layer is addressed.

The reality: Automated anomaly detection operates in the unknown-unknown layer of the detection pyramid (TH0.3) — it flags statistical deviations without understanding what they mean. Hunting operates in the known-unknown layer — it tests specific hypotheses about specific techniques. series_decompose_anomalies() is a hunting tool, not a hunting replacement. It identifies which data points are statistically unusual. The analyst determines whether “unusual” means “compromised,” “legitimate change,” or “noise.” The function does the math. Hunting does the judgment.

Extend this function

series_decompose_anomalies() accepts additional parameters for advanced use: `Seasonality` (auto-detect or specify the period — e.g., 7 for weekly), `Trend` (enable/disable trend detection), and `Test_points` (number of points at the end of the series to test for anomalies, leaving the rest as baseline only). The `Test_points` parameter is particularly useful for hunting: set it to 7 (for 7 days of detection window) with a 30-day series, and the function uses the first 23 days as baseline and tests only the last 7 days for anomalies — equivalent to the baseline + detection window pattern from TH1.10.


References Used in This Subsection

You're reading the free modules of this course

The full course continues with advanced topics, production detection rules, worked investigation scenarios, and deployable artifacts. Premium subscribers get access to all courses.

View Pricing See Full Syllabus