98 lines
3.2 KiB
Go
98 lines
3.2 KiB
Go
// Package detect provides anomaly detection algorithms and ensemble logic.
|
||
package detect
|
||
|
||
import (
|
||
"fmt"
|
||
"log"
|
||
|
||
"codeberg.org/pata1704/copod"
|
||
"codeberg.org/pata1704/guenther/pkg/types"
|
||
)
|
||
|
||
// COPODDetector implements the AnomalyDetector interface by wrapping the
|
||
// external codeberg.org/pata1704/copod package.
|
||
//
|
||
// Streaming mode: Score calls Update internally, so the sliding-window buffer
|
||
// stays current without requiring a separate Update call. Callers (like SEAD)
|
||
// only need to call Score per time step.
|
||
//
|
||
// Fit seeds the buffer with a batch of normal vectors. If Fit is not called
|
||
// the detector starts cold and returns score=0 until the buffer has enough
|
||
// points (controlled by bufferSize in the underlying library).
|
||
type COPODDetector struct {
|
||
detector *copod.Detector
|
||
}
|
||
|
||
// NewCOPODDetector initialises the streaming COPOD detector wrapper.
|
||
//
|
||
// - bufferSize: sliding-window capacity. Recommended: 100–200.
|
||
// - threshold: score cutoff for standalone IsAnomaly. When used inside
|
||
// SEAD the threshold is ignored (SEAD applies its own adaptive threshold).
|
||
func NewCOPODDetector(bufferSize int, threshold float64) (*COPODDetector, error) {
|
||
det, err := copod.NewDetector(bufferSize, threshold)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("copod: initialize wrapped detector: %w", err)
|
||
}
|
||
return &COPODDetector{
|
||
detector: det,
|
||
}, nil
|
||
}
|
||
|
||
// Fit seeds the COPOD history buffer with a slice of labelled-normal vectors.
|
||
func (c *COPODDetector) Fit(vectors []types.FeatureVector) error {
|
||
for _, v := range vectors {
|
||
if err := c.update(v); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Update adds a single observation to the sliding window.
|
||
// Safe to call concurrently with Score.
|
||
func (c *COPODDetector) Update(vector types.FeatureVector) error {
|
||
return c.update(vector)
|
||
}
|
||
|
||
// Score computes the COPOD anomaly score for the given vector and
|
||
// simultaneously updates the internal sliding window with the scored vector.
|
||
//
|
||
// The self-update ensures COPOD's buffer reflects the current data stream
|
||
// without requiring a separate Update call after every Score. This is
|
||
// consistent with the RRCF and IsolationForest detectors which also
|
||
// update themselves inside Score.
|
||
func (c *COPODDetector) Score(vector types.FeatureVector) (types.AnomalyResult, error) {
|
||
vec := copod.FeatureVector{
|
||
NormalizedVector: vector.NormalizedVector,
|
||
Timestamp: vector.Timestamp,
|
||
}
|
||
|
||
// Score first, then append to the buffer so the scored point does not
|
||
// bias its own copula calculation (score-then-insert, same as RRCF).
|
||
res, err := c.detector.Score(vec)
|
||
if err != nil {
|
||
return types.AnomalyResult{}, fmt.Errorf("copod: score: %w", err)
|
||
}
|
||
|
||
if err := c.update(vector); err != nil {
|
||
// Log but don't fail: the score is already computed.
|
||
log.Printf("copod: update after score: %v", err)
|
||
}
|
||
|
||
return types.AnomalyResult{
|
||
Timestamp: res.Timestamp,
|
||
Score: res.Score,
|
||
IsAnomaly: res.IsAnomaly,
|
||
Confidence: res.Confidence,
|
||
Method: res.Method,
|
||
}, nil
|
||
}
|
||
|
||
// update is the internal helper that adds vector to the copod sliding window.
|
||
func (c *COPODDetector) update(vector types.FeatureVector) error {
|
||
vec := copod.FeatureVector{
|
||
NormalizedVector: vector.NormalizedVector,
|
||
Timestamp: vector.Timestamp,
|
||
}
|
||
return c.detector.Update(vec)
|
||
}
|