packages feed

hanalyze-0.2.0.0: src/Hanalyze/Stat/Interpret.hs

{-# LANGUAGE OverloadedStrings #-}
-- |
-- Module      : Hanalyze.Stat.Interpret
-- Description : モデル解釈ツール (permutation importance / partial dependence / ICE)
-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
-- License     : BSD-3-Clause
--
-- Model interpretability tools.
--
-- Model-agnostic explanations of predictions:
--
--   * 'permutationImportance' — feature importance by random shuffling
--     (Breiman 2001).
--   * 'partialDependence' — marginal effect of a feature on predictions
--     (Friedman 2001).
--   * 'icePlot' — individual conditional expectation curves (Goldstein
--     et al. 2015).
--
-- These work on any black-box model exposed as a function
-- @predict :: [Double] -> Double@ or @[[Double]] -> [Double]@; the
-- caller is responsible for plumbing in their fitted model.
module Hanalyze.Stat.Interpret
  ( -- * Permutation feature importance
    PermutationConfig (..)
  , defaultPermutationConfig
  , PermutationImportance (..)
  , permutationImportance
    -- * Partial dependence
  , PDPResult (..)
  , partialDependence
    -- * Individual conditional expectation
  , ICEResult (..)
  , icePlot
  ) where

import qualified System.Random.MWC     as MWC
import qualified Data.Vector           as V
import qualified Data.Vector.Mutable   as VM
import           Control.Monad         (forM, forM_)

-- ---------------------------------------------------------------------------
-- Permutation feature importance
-- ---------------------------------------------------------------------------

-- | Configuration for permutation importance.
data PermutationConfig = PermutationConfig
  { pcNRepeats :: !Int
    -- ^ Number of times to shuffle each feature (Breiman recommends 10-30).
  } deriving (Show, Eq)

-- | Default: 30 repeats.
defaultPermutationConfig :: PermutationConfig
defaultPermutationConfig = PermutationConfig { pcNRepeats = 30 }

-- | Result of permutation importance.
data PermutationImportance = PermutationImportance
  { piMeanImportance :: ![Double]   -- ^ Per-feature mean drop in score.
  , piStdImportance  :: ![Double]   -- ^ Per-feature std dev across repeats.
  , piBaselineScore  :: !Double     -- ^ Score on un-shuffled data.
  } deriving (Show)

-- | Compute permutation importance for each feature.
--
-- For each feature @j@:
--
--   1. Shuffle column @j@ across rows.
--   2. Predict and compute score.
--   3. Importance @= baseline_score − shuffled_score@.
--
-- A higher score means the feature was more important.
--
-- The user supplies:
--
--   * a predict function @[[Double]] -> [Double]@,
--   * a score function comparing true vs predicted (e.g. accuracy,
--     R²; higher is better).
permutationImportance
  :: PermutationConfig
  -> ([[Double]] -> [Double])     -- ^ Predict.
  -> ([Double] -> [Double] -> Double)  -- ^ Score (true, pred -> Double).
  -> [[Double]]                   -- ^ Test X.
  -> [Double]                     -- ^ True y.
  -> MWC.GenIO
  -> IO PermutationImportance
permutationImportance cfg predict score xs ys gen =
  let nFeat = if null xs then 0 else length (head xs)
      nReps = pcNRepeats cfg
      baseline = score ys (predict xs)
  in do
    perFeat <- forM [0 .. nFeat - 1] $ \j -> do
      drops <- forM [1 .. nReps] $ \_ -> do
        xsShuffled <- shuffleColumn j xs gen
        let predShuf = predict xsShuffled
            scoreShuf = score ys predShuf
        pure (baseline - scoreShuf)
      let n     = fromIntegral nReps :: Double
          mean  = sum drops / n
          var   = sum [(d - mean) ^ (2 :: Int) | d <- drops]
                  / max 1 (n - 1)
      pure (mean, sqrt var)
    pure PermutationImportance
      { piMeanImportance = map fst perFeat
      , piStdImportance  = map snd perFeat
      , piBaselineScore  = baseline
      }

-- | Shuffle column @j@ of a 2D feature matrix.
shuffleColumn :: Int -> [[Double]] -> MWC.GenIO -> IO [[Double]]
shuffleColumn j xs gen = do
  let column = [row !! j | row <- xs]
  shuffled <- shuffleList column gen
  pure [ [if k == j then shuffled !! i else row !! k
         | k <- [0 .. length row - 1]]
       | (i, row) <- zip [0 ..] xs ]

-- ---------------------------------------------------------------------------
-- Partial dependence
-- ---------------------------------------------------------------------------

-- | Partial dependence plot result.
data PDPResult = PDPResult
  { pdpFeatureValues :: ![Double]     -- ^ Grid points for the chosen feature.
  , pdpMeanPredict   :: ![Double]     -- ^ Mean prediction at each grid point.
  } deriving (Show)

-- | Partial dependence: marginal effect of feature @j@ on prediction.
--
-- For each value @v@ on the grid:
--
--   1. Replace column @j@ with @v@ in every row of the dataset.
--   2. Predict on the modified dataset.
--   3. Average predictions to get @PD(v)@.
--
-- @
-- PD_j(v) = (1/n) Σ_i predict(replaceCol(x_i, j, v))
-- @
partialDependence
  :: ([[Double]] -> [Double])    -- ^ Predict.
  -> [[Double]]                  -- ^ Background X.
  -> Int                         -- ^ Feature index j.
  -> [Double]                    -- ^ Grid of values for feature j.
  -> PDPResult
partialDependence predict xs j grid =
  let pdAt v =
        let xsModified = [replaceAt j v row | row <- xs]
            preds = predict xsModified
        in sum preds / fromIntegral (length preds)
      means = [pdAt v | v <- grid]
  in PDPResult
       { pdpFeatureValues = grid
       , pdpMeanPredict   = means
       }

-- ---------------------------------------------------------------------------
-- Individual conditional expectation (ICE)
-- ---------------------------------------------------------------------------

-- | ICE plot result: one curve per row in the input, plus the average
-- (= partial dependence).
data ICEResult = ICEResult
  { iceFeatureValues :: ![Double]
  , iceCurves        :: ![[Double]]   -- ^ Per-sample prediction curves.
  , iceMean          :: ![Double]     -- ^ Average curve (= partial dep).
  } deriving (Show)

-- | Compute ICE curves: per-sample partial-dependence-style plots.
--
-- Same as partial dependence, but instead of averaging across samples
-- we keep each sample's curve. Useful for detecting heterogeneous
-- effects (interactions).
icePlot
  :: ([[Double]] -> [Double])    -- ^ Predict.
  -> [[Double]]                  -- ^ Samples (each gets its own curve).
  -> Int                         -- ^ Feature index j.
  -> [Double]                    -- ^ Grid of values.
  -> ICEResult
icePlot predict xs j grid =
  let -- For each grid value, predict for ALL samples (with feature j replaced).
      predsByGrid =
        [ predict [replaceAt j v row | row <- xs]
        | v <- grid ]
      -- Reshape: predsByGrid[g][i] → curves[i] is [predsByGrid[g][i] for g].
      curves =
        [ [ predsByGrid !! g !! i | g <- [0 .. length grid - 1] ]
        | i <- [0 .. length xs - 1] ]
      meanCurve =
        [ sum [predsByGrid !! g !! i | i <- [0 .. length xs - 1]]
          / fromIntegral (length xs)
        | g <- [0 .. length grid - 1] ]
  in ICEResult
       { iceFeatureValues = grid
       , iceCurves        = curves
       , iceMean          = meanCurve
       }

-- ---------------------------------------------------------------------------
-- Internal helpers
-- ---------------------------------------------------------------------------

-- | Replace element at position @i@ in a list.
replaceAt :: Int -> a -> [a] -> [a]
replaceAt _ _ []     = []
replaceAt 0 v (_:xs) = v : xs
replaceAt i v (x:xs) = x : replaceAt (i - 1) v xs

-- | Shuffle a list (Fisher-Yates).
shuffleList :: [a] -> MWC.GenIO -> IO [a]
shuffleList xs gen = do
  let n = length xs
  v <- V.thaw (V.fromList xs)
  forM_ [n - 1, n - 2 .. 1] $ \i -> do
    j <- MWC.uniformR (0, i) gen
    a <- VM.read v i
    b <- VM.read v j
    VM.write v i b
    VM.write v j a
  V.toList <$> V.freeze v