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:
CarterPerez-dev 2026-04-29 00:42:18 -04:00
parent e5c2f59675
commit 989635e7b0
3 changed files with 257 additions and 1 deletions

View File

@ -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

View File

@ -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)

View File

@ -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