Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion docs/source/api/models.rst
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,36 @@ Model API
:template: model_class
:nosignatures:

{% for name in api_names("sdm.models") if name != "ECOC" %}
{% for name in api_names("sdm.models") if name not in ("ECOC", "KumoAnomaly") %}
~sdm.models.{{ name }}
{% endfor %}

Anomaly Detection
-----------------

Unlike supervised ICL models, Kumo-Anomaly reconstructs an ordered series
without labels. Its scores are reconstruction errors, not probabilities.
Apply ``SCSThreshold`` to the returned ``scores`` and ``target``, or
``MACSThreshold`` to ``scores``, to obtain boolean anomaly flags. Thresholding
is separate from reconstruction so the same scores can be evaluated with
either decision rule without rerunning the model.

Both rules use the scored series itself, including a whole-series percentile
filter. Reference-only feature preprocessing does **not** make these thresholds
reference-fitted or causal. Defaults reproduce the released SDK's APCA-based
SCS and MACS paths; optional K-means segmentation is not included. MACS retains
the upstream expanding bounds shared by all three scales, rather than changing
them to rolling bounds. These heuristic settings do not provide calibrated
false-positive guarantees. The corrected DPM timestep mapping can change
scores and flags relative to the unmodified SDK.

.. autoclass:: sdm.models.KumoAnomaly
:members:

.. autoclass:: sdm.models.kumo.timeseries.anomaly.AnomalyOutput

.. autoclass:: sdm.models.kumo.timeseries.anomaly.SCSThreshold
:members:

.. autoclass:: sdm.models.kumo.timeseries.anomaly.MACSThreshold
:members:
38 changes: 38 additions & 0 deletions examples/kumo_anomaly.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""Score and flag anomalies with reference-only feature preprocessing."""

import torch

import sdm
from sdm.models import KumoAnomaly
from sdm.models.kumo.timeseries.anomaly import MACSThreshold, SCSThreshold

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
time = torch.arange(600, device=device, dtype=torch.float32)
values = torch.stack([torch.sin(time / 12), torch.cos(time / 20)], dim=-1)
values[450:460] += 3
series = sdm.TableTensor.from_tensor(values)
model = KumoAnomaly(device=device)

# Reference rows fit PCA/min-max only; model weights are already pretrained.
# Reconstruction uses future rows within each query window, so this is an
# offline detector, not a causal streaming evaluation.
result = model(
x=series[300:],
reference=series[:300],
num_samples=3,
num_steps=20,
generator=torch.Generator(device=device).manual_seed(42),
)
print(result.scores)
# These SDK rules use the scored query itself, including its global score
# percentile. Reference-only feature scaling does not make them causal or
# reference-calibrated. The corrected solver can change scores and decisions.
anomalies_scs = SCSThreshold()(result.scores, result.target)
anomalies_macs = MACSThreshold()(result.scores)
print("SCS anomaly rows:", anomalies_scs.nonzero().flatten() + 300)
print("MACS anomaly rows:", anomalies_macs.nonzero().flatten() + 300)
# The booleans are detector decisions, not ground truth or probabilities.
# Validate detection quality and threshold settings on your own labeled data.
8 changes: 7 additions & 1 deletion sdm/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@
from sdm.models.ecoc import ECOC
from sdm.models.tabiclv2 import TabICLv2
from sdm.models.tabfm import TabFM
from sdm.models.kumo import KumoTabular, KumoRelational, KumoForecast
from sdm.models.kumo import (
KumoTabular,
KumoRelational,
KumoForecast,
KumoAnomaly,
)
from sdm.models.timesfm3 import TimesFM3


Expand All @@ -26,5 +31,6 @@
"KumoTabular",
"KumoRelational",
"KumoForecast",
"KumoAnomaly",
"TimesFM3",
]
3 changes: 2 additions & 1 deletion sdm/models/kumo/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@

from sdm.models.kumo.tabular import KumoTabular
from sdm.models.kumo.relational import KumoRelational
from sdm.models.kumo.timeseries import KumoForecast
from sdm.models.kumo.timeseries import KumoForecast, KumoAnomaly


__all__ = [
"KumoTabular",
"KumoRelational",
"KumoForecast",
"KumoAnomaly",
]
3 changes: 3 additions & 0 deletions sdm/models/kumo/timeseries/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@

__lazy_modules__ = {
"sdm.models.kumo.timeseries.forecast",
"sdm.models.kumo.timeseries.anomaly",
}

from sdm.models.kumo.timeseries.forecast import KumoForecast
from sdm.models.kumo.timeseries.anomaly import KumoAnomaly


__all__ = [
"KumoForecast",
"KumoAnomaly",
]
9 changes: 9 additions & 0 deletions sdm/models/kumo/timeseries/anomaly/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,12 @@
# SPDX-License-Identifier: Apache-2.0

"""Kumo time-series anomaly detection model components."""

from sdm.models.kumo.timeseries.anomaly.model import KumoAnomaly, AnomalyOutput
from sdm.models.kumo.timeseries.anomaly.threshold import (
SCSThreshold,
MACSThreshold,
)


__all__ = ["KumoAnomaly", "AnomalyOutput", "SCSThreshold", "MACSThreshold"]
47 changes: 47 additions & 0 deletions sdm/models/kumo/timeseries/anomaly/ckpt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

from typing import Any

from torch import Tensor


def model_kwargs(config: dict[str, Any]) -> dict[str, Any]:
"""Read architecture from the checkpoint's embedded training config.

The separately published YAML describes a different feature width.
Only the released conditional model is supported; an unconditional
checkpoint must not silently load into a different conditioning path.
"""
model, diffusion = config["model"], config["diffusion"]
if model["is_unconditional"]:
raise ValueError("KumoAnomaly requires a conditional checkpoint")
return {
"num_features": model["target_dim"],
"time_channels": model["timeemb"],
"feature_channels": model["featureemb"],
"channels": diffusion["channels"],
"num_layers": diffusion["layers"],
"num_heads": diffusion["nheads"],
"embedding_channels": diffusion["diffusion_embedding_dim"],
"num_steps": diffusion["num_steps"],
"beta_start": diffusion["beta_start"],
"beta_end": diffusion["beta_end"],
"schedule": diffusion["schedule"],
}


def remap_ckpt(state: dict[str, Tensor]) -> dict[str, Tensor]:
"""Map upstream parameter names, preserving all keys for strict loading."""
out = {}
for key, value in state.items():
if key.startswith("diffmodel."):
key = "denoiser." + key.removeprefix("diffmodel.")
key = key.replace("time_layer.layers.0.", "time_layer.")
key = key.replace("feature_layer.layers.0.", "feature_layer.")
elif key.startswith(
("embed_layer.", "embed_norm.", "time_embed_norm.")
):
key = "side_embedding." + key
out[key] = value
return out
148 changes: 148 additions & 0 deletions sdm/models/kumo/timeseries/anomaly/denoiser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

import math
from typing import Any

import torch
from torch import Tensor
from torch.nn import Conv1d, Embedding, LayerNorm, ModuleList

from sdm.models.kumo.timeseries.anomaly.block import ResidualBlock
from sdm.models.kumo.timeseries.anomaly.embedding import DiffusionEmbedding


class DiffusionDenoiser(torch.nn.Module):
"""Predict noise for one Kumo-Anomaly diffusion step.

Callers supply normalized noisy/observed inputs and precomputed side
information. This component does not sample or compute anomaly scores.

Args:
channels: Width of the residual blocks.
side_channels: Width of the precomputed conditioning information.
num_steps: Number of discrete diffusion steps.
num_layers: Number of residual blocks, at least one.
num_heads: Attention heads for each axis.
embedding_channels: Even diffusion and strategy embedding width,
at least four.
input_channels: Input width (two for conditional, one unconditional).
hidden_channels: Transformer feedforward width.
dropout: Transformer attention and feedforward dropout probability.
device: The device.
dtype: The parameter and output dtype.
"""

def __init__(
self,
channels: int,
side_channels: int,
num_steps: int,
num_layers: int,
num_heads: int,
embedding_channels: int = 128,
input_channels: int = 2,
hidden_channels: int = 64,
dropout: float = 0.1,
device: torch.device | str | None = None,
dtype: torch.dtype | None = None,
) -> None:
super().__init__()
factory_kwargs: dict[str, Any] = {"device": device, "dtype": dtype}
self.diffusion_embedding = DiffusionEmbedding(
num_steps=num_steps,
channels=embedding_channels,
**factory_kwargs,
)
self.strategy_embedding = Embedding(
num_embeddings=2,
embedding_dim=embedding_channels,
**factory_kwargs,
)
self.input_projection = Conv1d(
in_channels=input_channels,
out_channels=channels,
kernel_size=1,
**factory_kwargs,
)
self.output_projection1 = Conv1d(
in_channels=channels,
out_channels=channels,
kernel_size=1,
**factory_kwargs,
)
self.output_projection2 = Conv1d(
in_channels=channels,
out_channels=1,
kernel_size=1,
**factory_kwargs,
)
for projection in (self.input_projection, self.output_projection1):
torch.nn.init.kaiming_normal_(
tensor=projection.weight, mode="fan_out", nonlinearity="relu"
)
with torch.no_grad():
projection.weight.mul_(0.5)
torch.nn.init.xavier_uniform_(
tensor=self.output_projection2.weight, gain=0.01
)
self.residual_layers = ModuleList(
ResidualBlock(
channels=channels,
side_channels=side_channels,
embedding_channels=embedding_channels,
num_heads=num_heads,
hidden_channels=hidden_channels,
dropout=dropout,
**factory_kwargs,
)
for _ in range(num_layers)
)
self.norm_after_skip = LayerNorm(channels, **factory_kwargs)
self.norm_after_proj1 = LayerNorm(channels, **factory_kwargs)

def forward(
self,
x: Tensor,
side_info: Tensor,
step: Tensor,
strategy: Tensor,
) -> Tensor:
"""Predict noise for normalized time-series windows.

Args:
x: Input with shape ``[B, I, K, L]``, where ``B`` is batch size,
``I`` input channels, ``K`` features, and ``L`` time steps.
side_info: Conditioning with shape ``[B, S, K, L]``, where
``S`` is ``side_channels``.
step: Integer diffusion indices in ``[0, num_steps)`` with
shape ``[B]`` or ``[1]``.
strategy: Integer masking-strategy indices in ``{0, 1}`` with
shape ``[B]`` or ``[1]``. Single-entry indices broadcast
across the batch independently for either embedding.

Returns:
Predicted noise with shape ``[B, K, L]`` in input feature/time
order. No denormalization or mask restoration is applied.
"""
batch, _, features, length = x.shape
x = self.input_projection(x.flatten(2)).relu()
x = x.reshape(batch, -1, features, length)
diffusion_embedding = self.diffusion_embedding(step)
strategy_embedding = self.strategy_embedding(strategy)
skips = []
for layer in self.residual_layers:
x, skip = layer(
x=x,
side_info=side_info,
diffusion_embedding=diffusion_embedding,
strategy_embedding=strategy_embedding,
)
skips.append(skip)
x = torch.stack(skips).sum(dim=0) / math.sqrt(len(skips))
x = self.norm_after_skip(x.flatten(2).transpose(1, 2)) # [B, K * L, C]
x = self.output_projection1(x.transpose(1, 2))
x = self.norm_after_proj1(x.transpose(1, 2)).transpose(1, 2)
return self.output_projection2(x.relu()).reshape(
batch, features, length
)
Loading
Loading