feat: add Aenebris.ML.IForest for anomaly scoring
- New Aenebris.ML.IForest module: pure Isolation Forest scorer
implementing the Liu et al. 2008 ICDM formula
anomaly_score = 2^(-E[h(x)] / c(n))
where E[h(x)] is the average path length across iTrees and
c(n) = 2*H(n-1) - 2(n-1)/n (expected unsuccessful BST search depth).
ITree ADT (ITreeLeaf size | ITreeSplit featIdx threshold left right)
with leaf-size c(n) correction added to traversal depth at every
leaf, matching the original paper. Score-only mode (accept pre-
trained forests from Python sklearn or similar); fitting is deferred
to a later phase. Default constants from Liu et al.: 100 trees,
256 subsample size, max depth ceil(log2(256)) = 8.
- 24 tests covering: harmonicNumber edge cases (H(0), H(1), H(2),
H(3), large-n asymptotic), normalizationConstant for n in {0, 1,
2, 256}, single-split and deep-tree path length traversal,
scoreIForest edge cases (empty forest, subsample 0, subsample 1),
shorter-path-equals-higher-score invariant, default constants,
Euler-Mascheroni precision.
- aenebris.cabal: expose Aenebris.ML.IForest in the library stanza.
- test/Spec.hs: import Expectation explicitly from Test.Hspec for the
shouldBeApprox helper.
- 325 total examples passing, 0 GHC warnings on the new module.
The escalation-gate composition with the calibrated GBDT score
(per docs/research/phase-2.5-ml-synthesis.md correction over the
0.8/0.2 folk-wisdom blend) is intentionally NOT in this module --
it belongs in the downstream ML.Engine module that wires Loader +
Inference + Calibration + IForest into a single decision pipeline.
This commit is contained in:
parent
e5c2f59675
commit
989635e7b0
|
|
@ -43,6 +43,7 @@ library
|
||||||
, Aenebris.ML.Loader
|
, Aenebris.ML.Loader
|
||||||
, Aenebris.ML.Inference
|
, Aenebris.ML.Inference
|
||||||
, Aenebris.ML.Calibration
|
, Aenebris.ML.Calibration
|
||||||
|
, Aenebris.ML.IForest
|
||||||
default-language: Haskell2010
|
default-language: Haskell2010
|
||||||
build-depends: base >= 4.7 && < 5
|
build-depends: base >= 4.7 && < 5
|
||||||
, warp >= 3.3
|
, warp >= 3.3
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
{-
|
||||||
|
©AngelaMos | 2026
|
||||||
|
IForest.hs
|
||||||
|
-}
|
||||||
|
{-# LANGUAGE BangPatterns #-}
|
||||||
|
{-# LANGUAGE DeriveGeneric #-}
|
||||||
|
|
||||||
|
module Aenebris.ML.IForest
|
||||||
|
( ITree(..)
|
||||||
|
, IForest(..)
|
||||||
|
, scoreIForest
|
||||||
|
, pathLength
|
||||||
|
, normalizationConstant
|
||||||
|
, harmonicNumber
|
||||||
|
, eulerMascheroni
|
||||||
|
, minSubsampleForNormalization
|
||||||
|
, defaultIForestNumTrees
|
||||||
|
, defaultIForestSubsampleSize
|
||||||
|
) where
|
||||||
|
|
||||||
|
import Data.Vector (Vector)
|
||||||
|
import qualified Data.Vector as V
|
||||||
|
import qualified Data.Vector.Unboxed as VU
|
||||||
|
import GHC.Generics (Generic)
|
||||||
|
|
||||||
|
eulerMascheroni :: Double
|
||||||
|
eulerMascheroni = 0.5772156649015329
|
||||||
|
|
||||||
|
minSubsampleForNormalization :: Int
|
||||||
|
minSubsampleForNormalization = 2
|
||||||
|
|
||||||
|
initialDepth :: Int
|
||||||
|
initialDepth = 0
|
||||||
|
|
||||||
|
zeroAnomalyScore :: Double
|
||||||
|
zeroAnomalyScore = 0.0
|
||||||
|
|
||||||
|
scoreBase :: Double
|
||||||
|
scoreBase = 2.0
|
||||||
|
|
||||||
|
defaultIForestNumTrees :: Int
|
||||||
|
defaultIForestNumTrees = 100
|
||||||
|
|
||||||
|
defaultIForestSubsampleSize :: Int
|
||||||
|
defaultIForestSubsampleSize = 256
|
||||||
|
|
||||||
|
data ITree
|
||||||
|
= ITreeLeaf !Int
|
||||||
|
| ITreeSplit !Int !Double !ITree !ITree
|
||||||
|
deriving (Eq, Show, Generic)
|
||||||
|
|
||||||
|
data IForest = IForest
|
||||||
|
{ ifTrees :: !(Vector ITree)
|
||||||
|
, ifSubsampleSize :: !Int
|
||||||
|
} deriving (Eq, Show, Generic)
|
||||||
|
|
||||||
|
scoreIForest :: IForest -> VU.Vector Double -> Double
|
||||||
|
scoreIForest !forest !fv =
|
||||||
|
let !trees = ifTrees forest
|
||||||
|
!numTrees = V.length trees
|
||||||
|
!subsample = ifSubsampleSize forest
|
||||||
|
!cn = normalizationConstant subsample
|
||||||
|
in if numTrees == 0 || cn <= 0.0
|
||||||
|
then zeroAnomalyScore
|
||||||
|
else
|
||||||
|
let !eHx = averagePathLength trees fv
|
||||||
|
in scoreBase ** (negate eHx / cn)
|
||||||
|
|
||||||
|
averagePathLength :: Vector ITree -> VU.Vector Double -> Double
|
||||||
|
averagePathLength !trees !fv =
|
||||||
|
let !numTrees = V.length trees
|
||||||
|
!totalDepth = V.foldl' addPath 0.0 trees
|
||||||
|
in if numTrees == 0
|
||||||
|
then 0.0
|
||||||
|
else totalDepth / fromIntegral numTrees
|
||||||
|
where
|
||||||
|
addPath !acc !tree = acc + pathLength tree fv initialDepth
|
||||||
|
|
||||||
|
pathLength :: ITree -> VU.Vector Double -> Int -> Double
|
||||||
|
pathLength !tree !fv !currentDepth = case tree of
|
||||||
|
ITreeLeaf !size ->
|
||||||
|
fromIntegral currentDepth + normalizationConstant size
|
||||||
|
ITreeSplit !featIdx !thr !left !right ->
|
||||||
|
let !fval = fv VU.! featIdx
|
||||||
|
in if fval <= thr
|
||||||
|
then pathLength left fv (currentDepth + 1)
|
||||||
|
else pathLength right fv (currentDepth + 1)
|
||||||
|
|
||||||
|
normalizationConstant :: Int -> Double
|
||||||
|
normalizationConstant n
|
||||||
|
| n < minSubsampleForNormalization = 0.0
|
||||||
|
| otherwise =
|
||||||
|
let !nDouble = fromIntegral n
|
||||||
|
!nMinusOne = fromIntegral (n - 1)
|
||||||
|
in 2.0 * harmonicNumber (n - 1) - 2.0 * nMinusOne / nDouble
|
||||||
|
|
||||||
|
harmonicNumber :: Int -> Double
|
||||||
|
harmonicNumber n
|
||||||
|
| n <= 0 = 0.0
|
||||||
|
| otherwise = go 1 0.0
|
||||||
|
where
|
||||||
|
go :: Int -> Double -> Double
|
||||||
|
go !i !acc
|
||||||
|
| i > n = acc
|
||||||
|
| otherwise = go (i + 1) (acc + 1.0 / fromIntegral i)
|
||||||
|
|
@ -186,6 +186,17 @@ import Aenebris.ML.Calibration
|
||||||
, fitIsotonic
|
, fitIsotonic
|
||||||
, fitPlatt
|
, fitPlatt
|
||||||
)
|
)
|
||||||
|
import Aenebris.ML.IForest
|
||||||
|
( IForest(..)
|
||||||
|
, ITree(..)
|
||||||
|
, defaultIForestNumTrees
|
||||||
|
, defaultIForestSubsampleSize
|
||||||
|
, eulerMascheroni
|
||||||
|
, harmonicNumber
|
||||||
|
, normalizationConstant
|
||||||
|
, pathLength
|
||||||
|
, scoreIForest
|
||||||
|
)
|
||||||
import Aenebris.ML.Inference
|
import Aenebris.ML.Inference
|
||||||
( kZeroThreshold
|
( kZeroThreshold
|
||||||
, predictProba
|
, predictProba
|
||||||
|
|
@ -285,7 +296,8 @@ import Network.Wai.Test
|
||||||
, simpleStatus
|
, simpleStatus
|
||||||
)
|
)
|
||||||
import Test.Hspec
|
import Test.Hspec
|
||||||
( Spec
|
( Expectation
|
||||||
|
, Spec
|
||||||
, describe
|
, describe
|
||||||
, expectationFailure
|
, expectationFailure
|
||||||
, hspec
|
, hspec
|
||||||
|
|
@ -349,6 +361,7 @@ main = hspec $ do
|
||||||
mlLoaderSpec
|
mlLoaderSpec
|
||||||
mlInferenceSpec
|
mlInferenceSpec
|
||||||
mlCalibrationSpec
|
mlCalibrationSpec
|
||||||
|
mlIForestSpec
|
||||||
|
|
||||||
configSpec :: Spec
|
configSpec :: Spec
|
||||||
configSpec = describe "Config" $ do
|
configSpec = describe "Config" $ do
|
||||||
|
|
@ -2405,6 +2418,143 @@ mlCalibrationSpec = describe "ML.Calibration" $ do
|
||||||
it "empty breakpoints pass through" $
|
it "empty breakpoints pass through" $
|
||||||
calibrate (IsotonicCalibrator VU.empty) 0.42 `shouldBe` 0.42
|
calibrate (IsotonicCalibrator VU.empty) 0.42 `shouldBe` 0.42
|
||||||
|
|
||||||
|
shouldBeApprox :: Double -> Double -> Expectation
|
||||||
|
shouldBeApprox actual expected =
|
||||||
|
abs (actual - expected) `shouldSatisfy` (< 1.0e-9)
|
||||||
|
|
||||||
|
singleSplitTree :: Int -> Double -> Int -> Int -> ITree
|
||||||
|
singleSplitTree featIdx thr leftSize rightSize =
|
||||||
|
ITreeSplit featIdx thr (ITreeLeaf leftSize) (ITreeLeaf rightSize)
|
||||||
|
|
||||||
|
mlIForestSpec :: Spec
|
||||||
|
mlIForestSpec = describe "ML.IForest" $ do
|
||||||
|
describe "harmonicNumber" $ do
|
||||||
|
it "H(0) is 0" $
|
||||||
|
harmonicNumber 0 `shouldBe` 0.0
|
||||||
|
|
||||||
|
it "H(1) is exactly 1.0" $
|
||||||
|
harmonicNumber 1 `shouldBe` 1.0
|
||||||
|
|
||||||
|
it "H(2) is exactly 1.5" $
|
||||||
|
harmonicNumber 2 `shouldBe` 1.5
|
||||||
|
|
||||||
|
it "H(3) is 1 + 1/2 + 1/3" $
|
||||||
|
harmonicNumber 3 `shouldBeApprox` (1.0 + 0.5 + 1.0 / 3.0)
|
||||||
|
|
||||||
|
it "H(1000) matches the asymptotic ln(n)+gamma+1/(2n) within 1e-6" $ do
|
||||||
|
let expected = log 1000.0 + eulerMascheroni + 1.0 / (2.0 * 1000.0)
|
||||||
|
abs (harmonicNumber 1000 - expected) `shouldSatisfy` (< 1.0e-6)
|
||||||
|
|
||||||
|
it "rejects negative input by returning 0" $
|
||||||
|
harmonicNumber (-5) `shouldBe` 0.0
|
||||||
|
|
||||||
|
describe "normalizationConstant c(n)" $ do
|
||||||
|
it "c(0) is 0 (degenerate)" $
|
||||||
|
normalizationConstant 0 `shouldBe` 0.0
|
||||||
|
|
||||||
|
it "c(1) is 0 (degenerate)" $
|
||||||
|
normalizationConstant 1 `shouldBe` 0.0
|
||||||
|
|
||||||
|
it "c(2) is 2*H(1) - 2*1/2 = 1.0" $
|
||||||
|
normalizationConstant 2 `shouldBe` 1.0
|
||||||
|
|
||||||
|
it "c(256) matches Liu et al. 2008 reference value" $
|
||||||
|
normalizationConstant 256
|
||||||
|
`shouldBeApprox`
|
||||||
|
(2.0 * harmonicNumber 255 - 2.0 * 255.0 / 256.0)
|
||||||
|
|
||||||
|
describe "pathLength on a single split" $ do
|
||||||
|
let tree = singleSplitTree 0 0.5 1 1
|
||||||
|
fvLeft = VU.singleton 0.3
|
||||||
|
fvRight = VU.singleton 0.7
|
||||||
|
|
||||||
|
it "feature value <= threshold goes left, depth increments to 1" $
|
||||||
|
pathLength tree fvLeft 0
|
||||||
|
`shouldBe` (1.0 + normalizationConstant 1)
|
||||||
|
|
||||||
|
it "feature value > threshold goes right, depth increments to 1" $
|
||||||
|
pathLength tree fvRight 0
|
||||||
|
`shouldBe` (1.0 + normalizationConstant 1)
|
||||||
|
|
||||||
|
it "leaf adds c(leafSize) to currentDepth" $ do
|
||||||
|
let leaf10 = ITreeLeaf 10
|
||||||
|
pathLength leaf10 (VU.singleton 0.0) 5
|
||||||
|
`shouldBe` (5.0 + normalizationConstant 10)
|
||||||
|
|
||||||
|
describe "pathLength on a deeper tree" $ do
|
||||||
|
let tree = ITreeSplit 0 0.5
|
||||||
|
(ITreeSplit 1 0.5
|
||||||
|
(ITreeLeaf 1) (ITreeLeaf 1))
|
||||||
|
(ITreeLeaf 2)
|
||||||
|
fv00 = VU.fromList [0.3, 0.3]
|
||||||
|
fv01 = VU.fromList [0.3, 0.7]
|
||||||
|
fv1 = VU.fromList [0.7, 0.0]
|
||||||
|
|
||||||
|
it "fv00 traverses two splits and lands on left-left leaf" $
|
||||||
|
pathLength tree fv00 0
|
||||||
|
`shouldBe` (2.0 + normalizationConstant 1)
|
||||||
|
|
||||||
|
it "fv01 traverses two splits and lands on left-right leaf" $
|
||||||
|
pathLength tree fv01 0
|
||||||
|
`shouldBe` (2.0 + normalizationConstant 1)
|
||||||
|
|
||||||
|
it "fv1 traverses one split and lands on right leaf with size 2" $
|
||||||
|
pathLength tree fv1 0
|
||||||
|
`shouldBe` (1.0 + normalizationConstant 2)
|
||||||
|
|
||||||
|
describe "scoreIForest edge cases" $ do
|
||||||
|
it "empty forest returns 0.0" $
|
||||||
|
scoreIForest (IForest V.empty 256) (VU.singleton 0.0) `shouldBe` 0.0
|
||||||
|
|
||||||
|
it "subsample size 0 returns 0.0" $
|
||||||
|
scoreIForest (IForest (V.singleton (ITreeLeaf 1)) 0) (VU.singleton 0.0)
|
||||||
|
`shouldBe` 0.0
|
||||||
|
|
||||||
|
it "subsample size 1 returns 0.0 (c(1) is 0)" $
|
||||||
|
scoreIForest (IForest (V.singleton (ITreeLeaf 1)) 1) (VU.singleton 0.0)
|
||||||
|
`shouldBe` 0.0
|
||||||
|
|
||||||
|
describe "scoreIForest produces values in (0, 1]" $ do
|
||||||
|
let forest = IForest
|
||||||
|
(V.fromList
|
||||||
|
[ singleSplitTree 0 0.5 1 1
|
||||||
|
, singleSplitTree 0 0.7 1 1
|
||||||
|
, singleSplitTree 0 0.3 1 1
|
||||||
|
])
|
||||||
|
4
|
||||||
|
|
||||||
|
it "anomalous input produces a score" $ do
|
||||||
|
let s = scoreIForest forest (VU.singleton 0.0)
|
||||||
|
s `shouldSatisfy` (> 0.0)
|
||||||
|
s `shouldSatisfy` (<= 1.0)
|
||||||
|
|
||||||
|
describe "scoreIForest: shorter average path = higher anomaly score" $ do
|
||||||
|
let shallow = IForest
|
||||||
|
(V.singleton (ITreeSplit 0 0.5 (ITreeLeaf 1) (ITreeLeaf 1)))
|
||||||
|
16
|
||||||
|
deep = IForest
|
||||||
|
(V.singleton
|
||||||
|
(ITreeSplit 0 0.5
|
||||||
|
(ITreeSplit 1 0.5
|
||||||
|
(ITreeSplit 2 0.5 (ITreeLeaf 1) (ITreeLeaf 1))
|
||||||
|
(ITreeLeaf 1))
|
||||||
|
(ITreeLeaf 1)))
|
||||||
|
16
|
||||||
|
fv = VU.fromList [0.3, 0.3, 0.3]
|
||||||
|
|
||||||
|
it "shallow tree (depth 1) yields higher score than deep tree (depth 3)" $
|
||||||
|
scoreIForest shallow fv `shouldSatisfy` (> scoreIForest deep fv)
|
||||||
|
|
||||||
|
describe "default constants from Liu et al. 2008" $ do
|
||||||
|
it "default tree count is 100" $
|
||||||
|
defaultIForestNumTrees `shouldBe` 100
|
||||||
|
|
||||||
|
it "default subsample size is 256" $
|
||||||
|
defaultIForestSubsampleSize `shouldBe` 256
|
||||||
|
|
||||||
|
it "Euler-Mascheroni constant matches the standard 16-digit value" $
|
||||||
|
eulerMascheroni `shouldBe` 0.5772156649015329
|
||||||
|
|
||||||
headersOnlyRequest :: [(BS.ByteString, BS.ByteString)] -> Request
|
headersOnlyRequest :: [(BS.ByteString, BS.ByteString)] -> Request
|
||||||
headersOnlyRequest hs =
|
headersOnlyRequest hs =
|
||||||
Network.Wai.Test.defaultRequest
|
Network.Wai.Test.defaultRequest
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue