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.Inference
|
||||
, Aenebris.ML.Calibration
|
||||
, Aenebris.ML.IForest
|
||||
default-language: Haskell2010
|
||||
build-depends: base >= 4.7 && < 5
|
||||
, 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
|
||||
, fitPlatt
|
||||
)
|
||||
import Aenebris.ML.IForest
|
||||
( IForest(..)
|
||||
, ITree(..)
|
||||
, defaultIForestNumTrees
|
||||
, defaultIForestSubsampleSize
|
||||
, eulerMascheroni
|
||||
, harmonicNumber
|
||||
, normalizationConstant
|
||||
, pathLength
|
||||
, scoreIForest
|
||||
)
|
||||
import Aenebris.ML.Inference
|
||||
( kZeroThreshold
|
||||
, predictProba
|
||||
|
|
@ -285,7 +296,8 @@ import Network.Wai.Test
|
|||
, simpleStatus
|
||||
)
|
||||
import Test.Hspec
|
||||
( Spec
|
||||
( Expectation
|
||||
, Spec
|
||||
, describe
|
||||
, expectationFailure
|
||||
, hspec
|
||||
|
|
@ -349,6 +361,7 @@ main = hspec $ do
|
|||
mlLoaderSpec
|
||||
mlInferenceSpec
|
||||
mlCalibrationSpec
|
||||
mlIForestSpec
|
||||
|
||||
configSpec :: Spec
|
||||
configSpec = describe "Config" $ do
|
||||
|
|
@ -2405,6 +2418,143 @@ mlCalibrationSpec = describe "ML.Calibration" $ do
|
|||
it "empty breakpoints pass through" $
|
||||
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 hs =
|
||||
Network.Wai.Test.defaultRequest
|
||||
|
|
|
|||
Loading…
Reference in New Issue