Real-time IoT Change Point Detection with ML.NET

This post covers change point detection, which is better suited for identifying persistent shifts in behaviour rather than momentary spikes. Specifically, comparing the two approaches available in ML.NET: Independent and identically distributed (IID) and Singular Spectrum Analysis (SSA) Change Point Detection

Change point detection is useful because IoT devices don’t always fail dramatically. Often, there’s a gradual or sustained change:

  • Temperature sensor starts reading consistently higher
  • A vibration sensor baseline shifts due to wear
  • Power consumption increases due to cooling issues

The sample application implements IID detection using DetectIidChangePoint and SSA with DetectChangePointBySsa.

IID assumes that: Incoming data points are independent and drawn from the same distribution. When the statistical properties shift (mean, variance), a change point is flagged.

AlgorithmLimitationsBest for
IIDDoesn’t account for trends or seasonality
Sensitive to noise
Fast detection
Simple signals
Low computational overhead
SSAComputationally expensive
Requires tuning
Signals with trends or seasonality
Noisy real-world IoT data
More robust long-term monitoring

SSA assumes that: The observed time‑series can be decomposed into a combination of trend, periodic components(seasonality), and noise. A change point is flagged when these components no longer match the historical structure of the series

BehaviourIIDSSA
Initial sensitivityHighModerate
Long-term behaviourAlways sensitiveAdapts
Noise tolerancePoorGood
Drift detectionKeeps firingFires once, then stops

In the application’s program.cs roughly the first 90 lines discusses changes to configuration to tune the models

var changePointEngine = _changePointEngines.GetOrAdd(subscribedTopic, _ =>
   new Lazy<TimeSeriesPredictionEngine<Model.TimeSeriesData, Model.ChangePointPrediction>>(() =>
   {
      try
      {
         switch (subscribedTopicSettings.DetectionMode)
         {
            case Model.DetectionMode.IID:
               var empty = _MLContext.Data.LoadFromEnumerable(new List<Model.TimeSeriesData>());

               IidChangePointEstimator iidPipe = _MLContext.Transforms.DetectIidChangePoint(
                              outputColumnName: nameof(Model.ChangePointPrediction.Prediction),
                              inputColumnName: nameof(Model.TimeSeriesData.Value),
                              confidence: subscribedTopicSettings.IIDSettings.Confidence,
                              changeHistoryLength: subscribedTopicSettings.IIDSettings.ChangeHistoryLength);

               var iidModel = iidPipe.Fit(empty);
               var iidEngine = iidModel.CreateTimeSeriesEngine<Model.TimeSeriesData, Model.ChangePointPrediction>(_MLContext);

               Console.WriteLine($"{DateTime.UtcNow:yy-MM-dd HH:mm:ss:fff} Initialized IID change point engine for '{subscribedTopic}' conf:{subscribedTopicSettings.IIDSettings.Confidence}, changeHistory:{subscribedTopicSettings.IIDSettings.ChangeHistoryLength}, AnomalySide:{subscribedTopicSettings.IIDSettings.AnomalySide}");
               return iidEngine;

            case Model.DetectionMode.SSA:
               SsaChangePointEstimator ssaPipe = _MLContext.Transforms.DetectChangePointBySsa(
                                 outputColumnName: nameof(Model.ChangePointPrediction.Prediction),
                                 inputColumnName: nameof(Model.TimeSeriesData.Value),
                                 confidence: subscribedTopicSettings.SSASettings.Confidence,
                                 changeHistoryLength: subscribedTopicSettings.SSASettings.ChangeHistoryLength,
                                 trainingWindowSize: subscribedTopicSettings.SSASettings.TrainingWindowSize,
                                 seasonalityWindowSize: subscribedTopicSettings.SSASettings.SeasonalityWindowSize);

               var dataView = _MLContext.Data.LoadFromEnumerable(new List<Model.TimeSeriesData>());
               var ssaModel = ssaPipe.Fit(dataView);
               var ssaEngine = ssaModel.CreateTimeSeriesEngine<Model.TimeSeriesData, Model.ChangePointPrediction>(_MLContext);

               Console.WriteLine($"{DateTime.UtcNow:yy-MM-dd HH:mm:ss:fff} Initialized SSA change point engine for '{subscribedTopic}' conf:{subscribedTopicSettings.SSASettings.Confidence}, changeHistory:{subscribedTopicSettings.SSASettings.ChangeHistoryLength}, trainingWindow:{subscribedTopicSettings.SSASettings.TrainingWindowSize}, seasonalityWindow:{subscribedTopicSettings.SSASettings.SeasonalityWindowSize}, AnomalySide:{subscribedTopicSettings.SSASettings.AnomalySide}");
               return ssaEngine;
            default:
               throw new NotSupportedException($"Detection mode {subscribedTopicSettings.DetectionMode} is not supported.");
         }

As part of a refactoring of configuration settings post my Real-time IoT Spike and Change Point Detection with ML.NET post the IID and SSA parameters have been split into separate classes

{
   "ApplicationSettings": {
      /*   */
      "SubscribedTopics": {
         "device/AshleyS1/distance": {
            "InputQualityOfService": 0,
            "OutputTopic": "alerts,ashley/changepoint",
            "OutputQualityOfService": 1,
            "ContentType": "application/json",
            "InputMessageTransformFile": "Transforms/InputSKU101991042.cs",
            "OutputMessageTransformFile": "Transforms/ChangePointOutput.cs",

            "DetectionMode": "IID", // IID or SSA
            "IIDSettings": {
               "Confidence": 95.0,
               "ChangeHistoryLength": 20,
               "AnomalySide": "TwoSided" //Positive, Negative,TwoSided
            },
            "SSASettings": {
               "Confidence": 95.0,
               "ChangeHistoryLength": 20,
               "SeasonalityWindowSize": 1,
               "TrainingWindowSize": 800,
               "AnomalySide": "TwoSided" //Positive, Negative,TwoSided
            }
         },
         "device/AshleyS72/distance": {
            "InputQualityOfService": 0,
            "OutputTopic": "alerts,ashley/changepoint",
            "OutputQualityOfService": 1,
            "ContentType": "application/json",
            "InputMessageTransformFile": "Transforms/InputSKU101991042.cs",
            "OutputMessageTransformFile": "Transforms/ChangePointOutput.cs",

            "DetectionMode": "IID",
            "IIDSettings": {
               "Confidence": 55.0,
               "ChangeHistoryLength": 10,
               "AnomalySide": "Negative" //Positive, Negative,TwoSided
            },
            "SSASettings": {
               "Confidence": 95.0,
               "ChangeHistoryLength": 20,
               "SeasonalityWindowSize": 2,
               "TrainingWindowSize": 300,
               "AnomalySide": "TwoSided" //Positive, Negative,TwoSided
            }
         }
      }
   }
}

In ML.NET time-series detectors (SSA and IID), the model doesn’t start detecting immediately. They first need to observe enough data to establish what “normal” looks like.

ParameterDescriptionTypical IoT Values
changeHistoryLengthNumber of points to evaluate stability30-100
trainingWindowSizeHistorical learning window (SSA)100-500
seasonalWindowSizeHistorical learning window (SSA)depends on cycle
confidenceSensitivity threshold90-99
AnomalySideStatistically unusual signalPositive, Negative, TwoSided

SSA requires a training window: trainingWindowSize: 200, this means the model needs roughly 200 samples before it is stable and before that predictions are unreliable and should be ignored. IID uses changeHistoryLength: 50 this means roughly 50 points are used to estimate the distribution and this is effectively the minimum history needed.

Warmup Matters: In the early stages the model is very sensitive as it has little context, and the baseline is weak which results in lots of detections and false positives (the detection below was after trainingWindowSize values). In the middle stage i.e. after enough samples the noise characteristics have been learned and patterns recognised. In the late stage after the model has been running for a long time it is very stable and less sensitive to change which can be an issue.

To cope with this (in a production system), I would ignore early predictions, for IID: changeHistoryLength and for SSA: trainingWindowSize + changeHistoryLength. To improve startup time preloading and SSA model with historical data or a synthetic base line is worth considering.

If an SSA model has been running for a longtime, or after a major change, reset the model to restore sensitivity. If sensitivity reduces over time it maybe worth considering “Dual Detectors: SSA for long-term structure, and IID for short-term sensitivity.

//---------------------------------------------------------------------------------
// Copyright (c) May 2026, devMobile Software
//
using System.Text.Json;
using System.Text.Json.Serialization; // Do not remove this using directive as it is required for the JsonIgnoreCondition

using devMobile.IoT.MqttTransformers;

public class ChangePointOutputTransformer : IChangePointOutputMessageTransformer
{
   private static readonly JsonSerializerOptions _serializerOptions = new() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull };

   public byte[] Transform(string sourceTopic, double value, double rawScore, double pValue, double martingale)
   {
      var obj = new
      {
         DetectionType = "ChangePoint",
         Value = value,
         SourceTopic = sourceTopic,
         RawScore = rawScore,
         PValue = pValue,
         Martingale = martingale,
      };

      string payload = JsonSerializer.Serialize(obj, _serializerOptions);

      return System.Text.Encoding.UTF8.GetBytes(payload);
   }
}

In the most recent version of the output transform ChangePoint = true was changed to DetectionType = “ChangePoint” to make it easier to identify change point messages for processing.

NOTE: SSA will not reliably detect slow, continuous movement – even if the total change is large

I was “tripped up” by this, it is not a bug, it’s an algorithm design choice to optimise for detecting regime changes, not motion. SSA is excellent at detecting when a system changes, but once it has adapted, it will treat that new state as completely normal.”

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.