Skip to content

Commit 2187401

Browse files
FanBaoMSmatouskozakCopilot
authored
Fix SR-CNN missing anomalies when Period > 0 (#7610)
## Root cause Two domain-mismatch bugs in the `Period > 0` code path of `SrCnnEntireModeler`: **1. `Train()` — z-score statistics computed in the wrong domain.** `_mean` / `_std` were computed from the raw input series *before* deseasonality ran. The z-score "false anomaly" filter inside `SpectralResidual` then compared `_seriesToDetect[i]` (the deseasonalized residual) against those raw-domain stats, producing meaningless z-scores that suppressed real anomalies. **2. `GetMarginPeriod()` — `CalculateAnomalyScore` called with `exp` in the wrong domain.** `_ifftRe[i]` was passed as the expected-value argument. `CalculateAnomalyScore` computes `|exp - value| / unit`, which is only meaningful when both arguments share a domain. In the no-period sibling `GetMargin()`, this call is correct: `CalculateExpectedValueByFft(_deAnomalyData)` is invoked immediately beforehand and overwrites `_ifftRe` with the raw-domain expected value of the de-anomalized series — so `_ifftRe[i]` and `values[i]` share a domain there. `GetMarginPeriod()` was added later (#5202) and reused the same call shape without that prior overwrite. In the period > 0 path `_ifftRe` holds the SR saliency map's real component computed on the deseasonalized residual series, not a raw-domain expected value. The resulting score collapses to roughly `|raw_value| / unit` — proportional to raw magnitude rather than deviation from expectation — so real anomalies often score near zero. This looks like a copy-paste of the no-period call site without re-deriving what `_ifftRe` actually holds in the new path. ## Fix 1. Move the `sum` / `_mean` / `_std` computation in `Train()` to *after* the deseasonality call and compute the statistics against `_seriesToDetect` (the same series SR will actually score). 2. In `GetMarginPeriod()` pass `results[i][3]` (the raw-domain expected value just produced by `GetExpectedValuePeriod`) instead of `_ifftRe[i]`. The no-period path (`Train` with `_period == 0` and `GetMargin`) is correct as written and is unchanged. ## Test Adds `TestSrCnnAnomalyDetectorPhoneCalls`, ported from the [dotnet/samples PhoneCallsAnomalyDetection tutorial](https://learn.microsoft.com/dotnet/machine-learning/tutorials/phone-calls-anomaly-detection). Data file is taken verbatim from [dotnet/samples](https://github.com/dotnet/samples/tree/main/machine-learning/tutorials/PhoneCallsAnomalyDetection/Data). The test asserts `DetectSeasonality` returns 7 and that `DetectEntireAnomalyBySrCnn` flags exactly indices `{28, 44, 56, 70}`. Prior to this fix the same call detects zero anomalies on the same input. `Sensitivity = 87.0` is used to obtain the boundary width the tutorial was originally calibrated for under the v1.5.2 `_factors` table; the table was rewritten in #5436, so the tutorial's literal `Sensitivity = 64.0` now produces a much wider boundary that masks all but the strongest spike at index 28. --------- Co-authored-by: Matous Kozak <55735845+matouskozak@users.noreply.github.com> Co-authored-by: Matous Kozak <matouskozak@seznam.cz> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 6b55808 commit 2187401

3 files changed

Lines changed: 163 additions & 9 deletions

File tree

‎src/Microsoft.ML.TimeSeries/SrCnnEntireAnomalyDetector.cs‎

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -496,28 +496,32 @@ public void Train(double[] values, ref double[][] results)
496496
_minimumOriginValue = Double.MaxValue;
497497
_maximumOriginValue = Double.MinValue;
498498

499-
var sum = 0.0;
500-
var squareSum = 0.0;
501-
502499
Array.Resize(ref _seriesToDetect, values.Length);
503500
for (int i = 0; i < values.Length; ++i)
504501
{
505502
var value = values[i];
506503
_seriesToDetect[i] = value;
507504
_minimumOriginValue = Math.Min(_minimumOriginValue, value);
508505
_maximumOriginValue = Math.Max(_maximumOriginValue, value);
509-
sum += value;
510-
squareSum += value * value;
511506
}
512507

513-
_mean = sum / values.Length;
514-
_std = Math.Sqrt((squareSum - (sum * sum) / values.Length) / values.Length);
515-
516508
if (_period > 0)
517509
{
518510
_deseasonalityFunction.Deseasonality(ref values, _period, ref _seriesToDetect);
519511
}
520512

513+
var sum = 0.0;
514+
var squareSum = 0.0;
515+
for (int i = 0; i < values.Length; ++i)
516+
{
517+
var value = _seriesToDetect[i];
518+
sum += value;
519+
squareSum += value * value;
520+
}
521+
522+
_mean = sum / values.Length;
523+
_std = Math.Sqrt((squareSum - (sum * sum) / values.Length) / values.Length);
524+
521525
SpectralResidual(_seriesToDetect, results, _threshold);
522526

523527
//Optional Steps
@@ -812,7 +816,7 @@ private void GetMarginPeriod(double[] values, double[][] results, IReadOnlyList<
812816
//Step 11: Update Anomaly Score, Expected Value and Boundaries
813817
for (int i = 0; i < results.Length; ++i)
814818
{
815-
results[i][1] = CalculateAnomalyScore(values[i], _ifftRe[i], _units[i], results[i][0] > 0);
819+
results[i][1] = CalculateAnomalyScore(values[i], results[i][3], _units[i], results[i][0] > 0);
816820

817821
// adjust the expected value if the point is not anomaly
818822
if (results[i][0] == 0)

‎test/Microsoft.ML.TimeSeries.Tests/TimeSeriesDirectApi.cs‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -772,6 +772,77 @@ public void TestSrCnnAnomalyDetectorBigSpike(
772772

773773
}
774774

775+
private sealed class PhoneCallsData
776+
{
777+
#pragma warning disable CS0649
778+
[LoadColumn(0)]
779+
public string timestamp;
780+
781+
[LoadColumn(1)]
782+
public double value;
783+
#pragma warning restore CS0649
784+
}
785+
786+
private sealed class PhoneCallsPrediction
787+
{
788+
[VectorType(7)]
789+
public double[] Prediction { get; set; }
790+
}
791+
792+
[NativeDependencyFact("MklImports")]
793+
public void TestSrCnnAnomalyDetectorPhoneCalls()
794+
{
795+
var mlContext = new MLContext(1);
796+
797+
var dataPath = GetDataPath("Timeseries", "phone-calls.csv");
798+
799+
IDataView dataView = mlContext.Data.LoadFromTextFile<PhoneCallsData>(path: dataPath, hasHeader: true, separatorChar: ',');
800+
801+
int period = mlContext.AnomalyDetection.DetectSeasonality(dataView, nameof(PhoneCallsData.value));
802+
Assert.Equal(7, period);
803+
804+
var options = new SrCnnEntireAnomalyDetectorOptions()
805+
{
806+
Threshold = 0.3,
807+
Sensitivity = 87.0,
808+
DetectMode = SrCnnDetectMode.AnomalyAndMargin,
809+
Period = period,
810+
};
811+
812+
var outputDataView = mlContext.AnomalyDetection.DetectEntireAnomalyBySrCnn(dataView, nameof(PhoneCallsPrediction.Prediction), nameof(PhoneCallsData.value), options);
813+
814+
var predictions = mlContext.Data.CreateEnumerable<PhoneCallsPrediction>(
815+
outputDataView, reuseRowObject: false);
816+
817+
var expectedAnomalyScores = new Dictionary<int, double>
818+
{
819+
{ 28, 0.1545 },
820+
{ 44, 0.2667 },
821+
{ 56, 0.2154 },
822+
{ 70, 0.2516 },
823+
};
824+
825+
int k = 0;
826+
foreach (var prediction in predictions)
827+
{
828+
Assert.Equal(7, prediction.Prediction.Length);
829+
if (expectedAnomalyScores.TryGetValue(k, out double expectedScore))
830+
{
831+
Assert.Equal(1, prediction.Prediction[0]);
832+
Assert.Equal(expectedScore, prediction.Prediction[1], 0.01);
833+
}
834+
else
835+
{
836+
Assert.Equal(0, prediction.Prediction[0]);
837+
Assert.Equal(0, prediction.Prediction[1]);
838+
}
839+
840+
++k;
841+
}
842+
843+
Assert.Equal(78, k);
844+
}
845+
775846
[NativeDependencyTheory("MklImports"), CombinatorialData]
776847
public void TestSrCnnAnomalyDetectorWithSeasonalAnomalyData(
777848
[CombinatorialValues(SrCnnDeseasonalityMode.Stl, SrCnnDeseasonalityMode.Mean, SrCnnDeseasonalityMode.Median)] SrCnnDeseasonalityMode mode
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
timestamp,value
2+
2018/9/3,36.69670857
3+
2018/9/4,35.74160571
4+
2018/9/5,34.11781143
5+
2018/9/6,33.53363571
6+
2018/9/7,29.19957714
7+
2018/9/8,5.18315
8+
2018/9/9,5.324905714
9+
2018/9/10,36.69670857
10+
2018/9/11,35.74160571
11+
2018/9/12,34.11781143
12+
2018/9/13,33.53363571
13+
2018/9/14,29.19957714
14+
2018/9/15,5.18315
15+
2018/9/16,5.324905714
16+
2018/9/17,36.69670857
17+
2018/9/18,35.74160571
18+
2018/9/19,34.11781143
19+
2018/9/20,33.53363571
20+
2018/9/21,29.19957714
21+
2018/9/22,5.18315
22+
2018/9/23,5.324905714
23+
2018/9/24,36.69670857
24+
2018/9/25,35.74160571
25+
2018/9/26,34.11781143
26+
2018/9/27,33.53363571
27+
2018/9/28,29.19957714
28+
2018/9/29,5.18315
29+
2018/9/30,5.324905714
30+
2018/10/1,31.34386429
31+
2018/10/2,36.18100429
32+
2018/10/3,34.49893429
33+
2018/10/4,34.18594143
34+
2018/10/5,29.96867143
35+
2018/10/6,5.240491429
36+
2018/10/7,5.304298571
37+
2018/10/8,37.94839429
38+
2018/10/9,36.3811
39+
2018/10/10,35.76107429
40+
2018/10/11,35.28894143
41+
2018/10/12,31.08465286
42+
2018/10/13,5.931802857
43+
2018/10/14,5.476382857
44+
2018/10/15,36.23001857
45+
2018/10/16,35.51714
46+
2018/10/17,21.95187143
47+
2018/10/18,31.15111714
48+
2018/10/19,28.21996429
49+
2018/10/20,4.532814286
50+
2018/10/21,5.376088571
51+
2018/10/22,36.19748286
52+
2018/10/23,36.05797571
53+
2018/10/24,35.05092286
54+
2018/10/25,34.78529
55+
2018/10/26,31.19532857
56+
2018/10/27,5.655607143
57+
2018/10/28,5.925987143
58+
2018/10/29,26.61867857
59+
2018/10/30,35.35089571
60+
2018/10/31,34.09542571
61+
2018/11/1,28.74181
62+
2018/11/2,28.20479429
63+
2018/11/3,4.849405714
64+
2018/11/4,5.444168571
65+
2018/11/5,33.21586857
66+
2018/11/6,35.69544714
67+
2018/11/7,34.79379714
68+
2018/11/8,34.26969714
69+
2018/11/9,30.07392
70+
2018/11/10,3.18219
71+
2018/11/11,3.964938571
72+
2018/11/12,45.21586857
73+
2018/11/13,35.69544714
74+
2018/11/14,34.79379714
75+
2018/11/15,34.26969714
76+
2018/11/16,30.07392
77+
2018/11/17,5.266295714
78+
2018/11/18,5.386695714
79+
2018/11/19,33.80200857

0 commit comments

Comments
 (0)