packages feed

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

{-# LANGUAGE OverloadedStrings #-}
-- |
-- Module      : Hanalyze.Stat.CV
-- Description : クロスバリデーションのフレームワーク (fold 分割 + 汎用 crossValidate)
-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
-- License     : BSD-3-Clause
--
-- Cross-validation framework.
--
-- Provides train/validation splits and a generic 'crossValidate'
-- function that runs a user-supplied @fit@ + @score@ on each fold.
--
-- @
-- import Hanalyze.Stat.CV
-- import qualified System.Random.MWC as MWC
--
-- gen <- MWC.createSystemRandom
-- folds <- kFold 5 (LA.rows x) gen
-- scores <- crossValidate folds fitFn scoreFn (x, y)
-- let mean = sum scores / fromIntegral (length scores)
-- @
--
-- == Available split strategies
--
--   * 'kFold' (random k-fold)
--   * 'stratifiedKFold' (preserves class balance for classification)
--   * 'leaveOneOut'
--   * 'shuffleSplit' (random repeated train/test)
--   * 'timeSeriesSplit' (forward-chaining for time series)
--
-- All return @[Fold]@ where each 'Fold' is a pair @(trainIdx, testIdx)@.
module Hanalyze.Stat.CV
  ( -- * Fold types
    Fold
    -- * Split strategies
  , kFold
  , stratifiedKFold
  , leaveOneOut
  , shuffleSplit
  , timeSeriesSplit
    -- * Cross-validation
  , crossValidate
  , crossValidateScores
    -- * Hyperparameter search
  , gridSearchCV
  , GridSearchResult (..)
  ) where

import qualified Data.Map.Strict       as Map
import qualified Data.Vector           as V
import qualified Data.Vector.Mutable   as VM
import           Control.Monad         (forM, forM_)
import           Control.Monad.Primitive (PrimMonad, PrimState)
import           Data.List             (sortBy)
import           Data.Ord              (comparing)
import qualified System.Random.MWC     as MWC

-- ---------------------------------------------------------------------------
-- Fold types
-- ---------------------------------------------------------------------------

-- | A single train / test split: @(trainIdx, testIdx)@. Indices are
-- 0-based row numbers into the original data.
type Fold = ([Int], [Int])

-- ---------------------------------------------------------------------------
-- Split strategies
-- ---------------------------------------------------------------------------

-- | Random k-fold split. 'PrimMonad' 汎用 (mwc は 'PrimMonad' 汎用) ゆえ ST/IO 両経路で
-- 同コード。 IO 呼び出しは従来どおり。 純粋 (seed) 経路は呼び出し側で
-- @runST (MWC.initialize (V.singleton seed) >>= kFold k n)@ で完結 (Phase 70.7 = 罰則回帰
-- の λ CV 純粋化に使う・[[selectLambdaCV]])。
kFold
  :: PrimMonad m
  => Int            -- ^ Number of folds @k@.
  -> Int            -- ^ Total sample count @n@.
  -> MWC.Gen (PrimState m)
  -> m [Fold]
kFold k n gen
  | k < 2     = pure [(allIdx n, [])]
  | k > n     = leaveOneOut n
  | otherwise = do
      perm <- shuffleIndices n gen
      let foldSize  = n `div` k
          remainder = n `mod` k
          -- Fold sizes: first 'remainder' folds get 1 extra.
          sizes = [foldSize + (if i < remainder then 1 else 0) | i <- [0..k-1]]
          starts = scanl (+) 0 sizes
          ranges = [(s, s + sz) | (s, sz) <- zip starts sizes]
          allRows = take n perm
      pure [ let testIdx  = take (e - s) (drop s allRows)
                 trainIdx = take s allRows ++ drop e allRows
             in (trainIdx, testIdx)
           | (s, e) <- ranges ]

-- | Stratified k-fold: preserves class proportions in each fold.
stratifiedKFold
  :: Int            -- ^ Number of folds @k@.
  -> [Int]          -- ^ Class labels (length @n@).
  -> MWC.GenIO
  -> IO [Fold]
stratifiedKFold k labels gen
  | k < 2 = pure [(allIdx (length labels), [])]
  | otherwise = do
      let n         = length labels
          byClass   = Map.fromListWith (++)
                        [(l, [i]) | (i, l) <- zip [0..] labels]
      -- For each class, shuffle its indices and split into k folds.
      classFolds <- forM (Map.toList byClass) $ \(_, idxs) -> do
        shuffled <- shuffleList idxs gen
        let m         = length shuffled
            foldSize  = m `div` k
            remainder = m `mod` k
            sizes     = [foldSize + (if i < remainder then 1 else 0)
                        | i <- [0..k-1]]
            starts    = scanl (+) 0 sizes
            ranges    = [(s, s + sz) | (s, sz) <- zip starts sizes]
        pure [take (e - s) (drop s shuffled) | (s, e) <- ranges]
      -- Combine: fold i = concat of i-th sub-fold from each class.
      let testIdxByFold =
            [ concat [classFolds !! ci !! fi | ci <- [0 .. length classFolds - 1]]
            | fi <- [0 .. k - 1] ]
          allI = [0 .. n - 1]
      pure [ let testIdx  = sortBy compare ti
                 trainIdx = filter (`notElem` testIdx) allI
             in (trainIdx, testIdx)
           | ti <- testIdxByFold ]

-- | Leave-one-out cross-validation: @n@ folds, each test set is a
-- single row.
leaveOneOut :: Applicative f => Int -> f [Fold]
leaveOneOut n =
  pure [ ([j | j <- [0 .. n - 1], j /= i], [i]) | i <- [0 .. n - 1] ]

-- | Repeated random train/test split (Monte-Carlo CV).
shuffleSplit
  :: Int            -- ^ Number of repetitions.
  -> Double         -- ^ Test fraction (0 < t < 1).
  -> Int            -- ^ Total samples @n@.
  -> MWC.GenIO
  -> IO [Fold]
shuffleSplit nReps testFrac n gen = do
  let testN = max 1 (round (fromIntegral n * testFrac))
  forM [1 .. nReps] $ \_ -> do
    perm <- shuffleIndices n gen
    let testIdx  = take testN perm
        trainIdx = drop testN perm
    pure (trainIdx, testIdx)

-- | Time-series forward-chaining split. Fold @i@ uses the first
-- @initial + i × step@ samples for train and the next @step@ for test.
-- Useful for evaluating models on time-ordered data.
timeSeriesSplit
  :: Int            -- ^ Initial training set size.
  -> Int            -- ^ Step size (samples per test fold).
  -> Int            -- ^ Total samples.
  -> [Fold]
timeSeriesSplit initial step n =
  [ ([0 .. initial + (i - 1) * step - 1],
     [initial + (i - 1) * step .. initial + i * step - 1])
  | i <- [1 .. (n - initial) `div` step]
  ]

-- ---------------------------------------------------------------------------
-- Cross-validation
-- ---------------------------------------------------------------------------

-- | Run a fit / score loop over folds. Returns a score per fold.
--
-- The user provides:
--
--   * a function that takes (trainIdx, testIdx) and the dataset, fits
--     a model on the train indices, and returns predictions on the
--     test indices,
--   * a score function that compares true and predicted values.
--
-- For type generality the dataset and predictions are user-defined.
crossValidate
  :: [Fold]
  -> (([Int], [Int]) -> data_ -> IO pred_)  -- ^ fit + predict
  -> (data_ -> [Int] -> pred_ -> IO Double) -- ^ scoring fn (true vs pred)
  -> data_
  -> IO [Double]
crossValidate folds fitPredict scoreFn d =
  forM folds $ \fold@(_train, testIdx) -> do
    pred_ <- fitPredict fold d
    scoreFn d testIdx pred_

-- | Convenience: returns @(mean, std)@ of fold scores.
crossValidateScores
  :: [Fold]
  -> (([Int], [Int]) -> data_ -> IO pred_)
  -> (data_ -> [Int] -> pred_ -> IO Double)
  -> data_
  -> IO (Double, Double)
crossValidateScores folds fp sf d = do
  scores <- crossValidate folds fp sf d
  let n     = fromIntegral (length scores) :: Double
      mean  = sum scores / n
      var   = sum [(s - mean) ^ (2 :: Int) | s <- scores]
              / max 1 (n - 1)
  pure (mean, sqrt var)

-- ---------------------------------------------------------------------------
-- Grid search
-- ---------------------------------------------------------------------------

-- | Result of a grid search.
data GridSearchResult hp = GridSearchResult
  { gsBestParams :: hp
  , gsBestScore  :: !Double
  , gsAllResults :: ![(hp, Double, Double)]
    -- ^ (params, mean score, std of fold scores) for each grid point.
  } deriving (Show)

-- | Grid search over hyperparameters with k-fold CV. The user
-- provides:
--
--   * the list of HP values to try
--   * a function to fit/predict given an HP and a fold
--   * a scoring function (higher = better)
--
-- Returns the best HP plus full grid results.
gridSearchCV
  :: [Fold]
  -> [hp]                                              -- ^ HP grid
  -> (hp -> ([Int], [Int]) -> data_ -> IO pred_)       -- ^ fit/predict
  -> (data_ -> [Int] -> pred_ -> IO Double)            -- ^ score
  -> data_
  -> IO (GridSearchResult hp)
gridSearchCV folds grid fp sf d = do
  results <- forM grid $ \hp -> do
    (mean, std) <- crossValidateScores folds (fp hp) sf d
    pure (hp, mean, std)
  let (bestHp, bestScore, _) = head (sortBy (comparing (\(_, s, _) -> negate s)) results)
  pure GridSearchResult
    { gsBestParams = bestHp
    , gsBestScore  = bestScore
    , gsAllResults = results
    }

-- ---------------------------------------------------------------------------
-- Helpers
-- ---------------------------------------------------------------------------

allIdx :: Int -> [Int]
allIdx n = [0 .. n - 1]

-- | Fisher-Yates shuffle producing a list of indices. 'PrimMonad' 汎用 (ST/IO 両用)。
shuffleIndices :: PrimMonad m => Int -> MWC.Gen (PrimState m) -> m [Int]
shuffleIndices n gen = do
  v <- V.thaw (V.fromList [0 .. n - 1])
  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

-- | Shuffle an arbitrary list. 'PrimMonad' 汎用 (ST/IO 両用)。
shuffleList :: PrimMonad m => [a] -> MWC.Gen (PrimState m) -> m [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