Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,9 @@ The `detector_name` config value must be one of the strings the loader accepts
| `PageHinkleyDetector` | Page-Hinkley test (river) | ph_min_instances, ph_delta, ph_threshold, ph_alpha |
| `ModelPerformanceDetector` | evidently batch analysis | (uses evidently defaults) |
| `EvalDetector` | Direct eval comparison (`ModelEvalDetector`) | metric_index |
| `EnsembleDetector` | Voting over sub-detectors | ensemble_detectors, ensemble_voting |

Note: `EnsembleDetector` is recognized by the loader but raises `NotImplementedError` (sub-detector configuration is not wired up yet) -- do not use it.
`EnsembleDetector` builds each name in `ensemble_detectors` from the same `[drift_detection]` block (so a detector type can appear at most once) and combines their verdicts per `ensemble_voting`: `majority`, `any` (alias `or`), or `unanimous` (aliases `all`, `and`). An unknown voting name or an empty detector list raises `ValueError`.

### Available CL Update Modes
| Mode | Strategy | Key Params |
Expand Down
13 changes: 13 additions & 0 deletions docs/configurations.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,19 @@ ph_alpha = 0.9999
| `ph_threshold` | float | Page-Hinkley trigger threshold. |
| `ph_alpha` | float | Page-Hinkley forgetting factor. |

Ensemble:

```toml
detector_name = "EnsembleDetector"
ensemble_detectors = ["ADWINDetector", "KSWINDetector", "PageHinkleyDetector"]
ensemble_voting = "majority"
```

| Option | Type | Description |
|----------|------|-------------|
| `ensemble_detectors` | list[str] | Sub-detectors to combine. Each is built from this same `[drift_detection]` block, so a detector type can appear at most once. |
| `ensemble_voting` | str | How sub-detector verdicts combine: `majority` (more than half fire), `any`/`or` (at least one fires), `unanimous`/`all`/`and` (every one fires). |


Details about the drift detection algorithms available can be found in [docs/drift_detectors.md](drift_detectors.md)

Expand Down
40 changes: 31 additions & 9 deletions docs/drift_detectors.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ Defined in `src/drift_detection/detectors/base.py`:
| `ph_delta` | `0.005` | Page-Hinkley change magnitude parameter. |
| `ph_threshold` | `50` | Page-Hinkley trigger threshold. |
| `ph_alpha` | `0.9999` | Page-Hinkley forgetting factor. |
| `ensemble_detectors` | `()` | Sub-detector names combined by `EnsembleDetector`. |
| `ensemble_voting` | `"majority"` | Voting rule: `majority`, `any`/`or`, `unanimous`/`all`/`and`. |

## Detector Selection (`detector_name`)

Expand All @@ -53,8 +55,7 @@ Defined in `src/drift_detection/detectors/base.py`:
- `PageHinkleyDetector`
- `ModelPerformanceDetector`
- `EvalDetector` (maps to `ModelEvalDetector`)

`EnsembleDetector` is present as a class but intentionally not wired in the loader and raises `NotImplementedError`.
- `EnsembleDetector` (combines the detectors listed in `ensemble_detectors` under `ensemble_voting`)

## Detector Classes And Options

Expand Down Expand Up @@ -169,17 +170,38 @@ Integration note:

Brief Explanation:

Wraps several sub-detectors and combines their signals under a `voting` rule (`majority`, `unanimous`, `any`, or weighted) so you can trade sensitivity against false-alarm rate: e.g. `any` reacts to the first detector that fires while `unanimous` requires full agreement. It is conceptually useful for robustness, but note it is not currently loadable from config (see integration note).
Wraps several sub-detectors and combines their signals under a `voting` rule so you can trade sensitivity against false-alarm rate: `any` reacts to the first detector that fires, `unanimous` requires full agreement, `majority` sits in between.

Constructor options:
Config options:

- `detectors: list[BaseDriftDetector]`: sub-detectors whose signals are combined; more detectors = more robust but more compute.
- `voting`: `majority`, `unanimous`, `any`, or weighted fallback: how signals combine. `any` = most sensitive (first firing wins), `majority` = balanced, `unanimous` = most conservative (all must agree).
- `name`
- `ensemble_detectors`: list of sub-detector names to combine, e.g. `["ADWINDetector", "KSWINDetector"]`. Each is built from the same `[drift_detection]` block, so a given detector type can appear at most once. Required; an empty list raises `ValueError`.
- `ensemble_voting`: how the sub-detector verdicts combine (default `majority`):
- `majority` -- fires when strictly more than half the detectors fire. Balanced.
- `any` (alias `or`) -- fires when at least one detector fires. Most sensitive.
- `unanimous` (aliases `all`, `and`) -- fires only when every detector fires. Most conservative.
Names are case-insensitive; an unrecognized value raises `ValueError` at load time.

Integration note:
Behavior:

- All sub-detectors are updated on every call (no short-circuiting), so `detector.reset()` and internal windows stay in sync.
- The returned `drift_score` is the mean sub-detector score and `confidence` the mean of the non-`None` sub-detector confidences (`None` if no detector reports one), regardless of the voting rule.
- The regime is a plurality vote over the sub-detector regimes, independent of `drift_detected`.
- `metadata` carries `voting`, `n_votes`, `n_detectors`, and the per-detector verdicts.

Example:

- Class implementation exists, but dynamic config loading for sub-detectors is not implemented.
```toml
[drift_detection]
detector_name = "EnsembleDetector"
ensemble_detectors = ["ADWINDetector", "KSWINDetector", "PageHinkleyDetector"]
ensemble_voting = "majority"
detection_interval = 10

# Sub-detectors read their usual hyperparameters from this same block
adwin_delta = 0.002
kswin_alpha = 0.005
ph_threshold = 50
```

## How Monitor Uses Detectors

Expand Down
14 changes: 14 additions & 0 deletions src/apeiron/config/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,20 @@ class DriftDetectionCfg:
ph_threshold: float = 50
ph_alpha: float = 0.9999

# Ensemble hyperparameters (used when detector_name = "EnsembleDetector")
ensemble_detectors: tuple[str, ...] = ()
# "majority" | "any" (alias "or") | "unanimous" (aliases "all", "and")
ensemble_voting: str = "majority"

def __post_init__(self) -> None:
# TOML arrays arrive as lists; keep the frozen config immutable.
# A bare string (e.g. an unquoted --set that failed JSON parsing) is a
# single detector name, not an iterable of characters.
names = self.ensemble_detectors
if isinstance(names, str):
names = (names,)
object.__setattr__(self, "ensemble_detectors", tuple(names))


@dataclass(frozen=True)
class VisualizationCfg:
Expand Down
51 changes: 36 additions & 15 deletions src/apeiron/drift_detection/detectors/model_performance_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,15 @@ class EnsembleDetector(BaseDriftDetector):
Combines signals from multiple detectors to make more robust decisions.
"""

VOTING_STRATEGIES = {
"majority": "majority", # more than half of the detectors fire
"any": "any", # at least one detector fires
"or": "any",
"unanimous": "unanimous", # every detector fires
"all": "unanimous",
"and": "unanimous",
}

def __init__(
self,
detectors: List[BaseDriftDetector],
Expand All @@ -320,12 +329,23 @@ def __init__(

Args:
detectors: List of individual detectors
voting: Voting strategy ('majority', 'unanimous', 'any', 'weighted')
voting: Voting strategy. One of 'majority', 'any' (alias 'or'),
or 'unanimous' (aliases 'all', 'and').
name: Detector name
"""
super().__init__(name)
if not detectors:
raise ValueError("EnsembleDetector requires at least one sub-detector")

key = voting.strip().lower()
if key not in self.VOTING_STRATEGIES:
raise ValueError(
f"Unknown ensemble voting strategy: {voting!r}. "
f"Expected one of {sorted(self.VOTING_STRATEGIES)}."
)

self.detectors = detectors
self.voting = voting
self.voting = self.VOTING_STRATEGIES[key]
self._is_initialized = all(d._is_initialized for d in detectors)

def update(self, value: float, **kwargs) -> DriftSignal:
Expand All @@ -347,18 +367,17 @@ def update(self, value: float, **kwargs) -> DriftSignal:
signals.append(signal)
detector_names.append(detector.name)

# Average drift scores
avg_drift_score = np.mean([s.drift_score for s in signals])

# Combine signals based on voting strategy
n_votes = sum(s.drift_detected for s in signals)
if self.voting == "majority":
drift_detected = sum(s.drift_detected for s in signals) > len(signals) / 2
drift_detected = n_votes > len(signals) / 2
elif self.voting == "unanimous":
drift_detected = all(s.drift_detected for s in signals)
elif self.voting == "any":
drift_detected = any(s.drift_detected for s in signals)
else: # weighted - use average drift score
drift_detected = bool(np.mean([s.drift_score for s in signals]) > 0.5)

# Average drift scores
avg_drift_score = np.mean([s.drift_score for s in signals])
drift_detected = n_votes == len(signals)
else: # any
Comment thread
anagainaru marked this conversation as resolved.
Outdated
drift_detected = n_votes > 0

# Determine regime by majority vote
regime_votes = [s.regime for s in signals]
Expand All @@ -367,19 +386,21 @@ def update(self, value: float, **kwargs) -> DriftSignal:
# Combine metadata
metadata = {
"n_detectors": len(signals),
"voting": self.voting,
"n_votes": int(n_votes),
"individual_signals": [
{"detector": name, "detected": signal.drift_detected}
for name, signal in zip(detector_names, signals)
],
}

confidences = [s.confidence for s in signals if s.confidence is not None]

return DriftSignal(
regime=regime,
drift_detected=drift_detected,
drift_detected=bool(drift_detected),
drift_score=float(avg_drift_score),
confidence=float(
np.mean([s.confidence for s in signals if s.confidence is not None])
),
confidence=float(np.mean(confidences)) if confidences else None,
metadata=metadata,
)

Expand Down
55 changes: 37 additions & 18 deletions src/apeiron/drift_detection/load_drift_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,16 @@
from apeiron.drift_detection.detectors.base import BaseDriftDetector


def load_drift_detector(cfg: Config) -> BaseDriftDetector:
"""Dynamically load and instantiate a drift detector based on its name.
def _build_detector(detector_name: str, cfg: Config) -> BaseDriftDetector:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Config still should still hold the detector name in it. We should continue to derive detector name from the config instead of accepting it separately. It's slightly less error prone and a bit easier to use.

Changing it to this means every caller will do:
detector_name = cfg.drift_detection.detector_name
_build_detector(detector_name, cfg)

instead of:
_build_detector(cfg)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The load_drift_detection function is still using the detector name set in config. This change is necessary (i.e. splitting the load function into _build_detector that takes a given detector) to cover the EnsembleDetector. For the EnsembleDetector the config is like this:

detector_name = "EnsembleDetector"
ensemble_detectors = ["ADWINDetector", "KSWINDetector", "PageHinkleyDetector"]
ensemble_voting = "majority"

which means we are calling _build_detector for each detector in the list.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay so for the ensemble detector, the name that we're passing to buld_detector will differ from the name in the config? That's what it looks like based on the below code snippet. Odd behavior but it's an internal only method so I suppose it's fine.

detectors=[_build_detector(name, cfg) for name in sub_names]

"""Instantiate a single (non-ensemble) drift detector from the config.

Args:
detector_name (str): Name of the drift detector class to load.
detector_name (str): Name of the drift detector class to build.
cfg: Configuration object containing parameters for the detector.

Returns:
BaseDriftDetector: An instance of the specified drift detector.
"""
detector_name = cfg.drift_detection.detector_name

detector_instance: BaseDriftDetector
if detector_name == "ADWINDetector":
from apeiron.drift_detection.detectors.statistical_detectors import (
Expand Down Expand Up @@ -52,19 +50,6 @@ def load_drift_detector(cfg: Config) -> BaseDriftDetector:
)

detector_instance = ModelPerformanceDetector()
elif detector_name == "EnsembleDetector":
raise NotImplementedError(
"EnsembleDetector requires configuration of sub-detectors, "
"which is not yet implemented. Use ADWINDetector, KSWINDetector, "
"PageHinkleyDetector, or ModelPerformanceDetector instead."
)

# from apeiron.drift_detection.detectors.model_performance_detector import (
# EnsembleDetector,
# )

# detector_instance = EnsembleDetector()

elif detector_name == "EvalDetector":
from apeiron.drift_detection.detectors.model_performance_detector import (
ModelEvalDetector,
Expand All @@ -75,3 +60,37 @@ def load_drift_detector(cfg: Config) -> BaseDriftDetector:
raise ValueError(f"Unknown drift detector: {detector_name}")

return detector_instance


def load_drift_detector(cfg: Config) -> BaseDriftDetector:
"""Dynamically load and instantiate a drift detector based on its name.

Args:
cfg: Configuration object containing parameters for the detector.

Returns:
BaseDriftDetector: An instance of the specified drift detector.
"""
detector_name = cfg.drift_detection.detector_name

if detector_name != "EnsembleDetector":
return _build_detector(detector_name, cfg)

from apeiron.drift_detection.detectors.model_performance_detector import (
EnsembleDetector,
)

sub_names = cfg.drift_detection.ensemble_detectors
if not sub_names:
raise ValueError(
"EnsembleDetector requires [drift_detection] ensemble_detectors to list "
"at least one sub-detector, e.g. "
'ensemble_detectors = ["ADWINDetector", "KSWINDetector"]'
)
if "EnsembleDetector" in sub_names:
raise ValueError("EnsembleDetector cannot be nested inside itself")

return EnsembleDetector(
detectors=[_build_detector(name, cfg) for name in sub_names],
voting=cfg.drift_detection.ensemble_voting,
)
Loading
Loading