packages feed

hanalyze-0.2.0.0: src/Hanalyze/Design/Anova.hs

{-# LANGUAGE OverloadedStrings #-}
-- |
-- Module      : Hanalyze.Design.Anova
-- Description : 一元配置・二元配置 ANOVA/ANCOVA 表の算出 (F 値・p 値・η² 効果量)
-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
-- License     : BSD-3-Clause
--
-- ANOVA / ANCOVA tables.
--
-- Computes one-way and two-way analysis of variance, reporting F values,
-- p values, and the @η²@ effect size.
module Hanalyze.Design.Anova
  ( AnovaRow (..)
  , AnovaTable (..)
  , oneWayAnova
  , twoWayAnova
  , printAnovaTable
  ) where

import Data.Text (Text)
import qualified Data.Text as T
import Data.List (groupBy, sort)
import Data.Function (on)
import Text.Printf (printf)
import qualified Statistics.Distribution as SD
import qualified Statistics.Distribution.FDistribution as FD

-- | One row of an ANOVA table.
data AnovaRow = AnovaRow
  { arSource :: Text             -- ^ Source label.
  , arDF     :: Int              -- ^ Degrees of freedom.
  , arSS     :: Double           -- ^ Sum of squares.
  , arMS     :: Double           -- ^ Mean square (@SS / DF@).
  , arF      :: Maybe Double     -- ^ F statistic ('Nothing' for total / error rows).
  , arPVal   :: Maybe Double     -- ^ p-value.
  , arEtaSq  :: Maybe Double     -- ^ Effect size @η² = SS_factor / SS_total@.
  } deriving (Show)

-- | A complete ANOVA table.
newtype AnovaTable = AnovaTable [AnovaRow] deriving (Show)

-- | One-way ANOVA. The arguments are the group label per data point and
-- the corresponding values.
oneWayAnova :: [Text] -> [Double] -> AnovaTable
oneWayAnova labels values =
  let n         = length values
      grandMean = sum values / fromIntegral n
      groups    = groupBy ((==) `on` fst)
                $ sort (zip labels values)
      ssTotal   = sum [(v - grandMean)^(2::Int) | v <- values]
      -- グループ間平方和 (Between)
      ssBetween = sum
        [ let xs   = map snd g
              gm   = sum xs / fromIntegral (length xs)
              k    = length xs
          in fromIntegral k * (gm - grandMean)^(2::Int)
        | g <- groups ]
      ssWithin  = ssTotal - ssBetween
      kGroups   = length groups
      dfBetween = kGroups - 1
      dfWithin  = n - kGroups
      msBetween = ssBetween / fromIntegral dfBetween
      msWithin  = ssWithin  / fromIntegral dfWithin
      fStat     = msBetween / msWithin
      pVal      = if dfWithin <= 0 || msWithin <= 0
                    then 1
                    else SD.complCumulative
                          (FD.fDistribution dfBetween dfWithin) fStat
      etaSq     = ssBetween / ssTotal
  in AnovaTable
      [ AnovaRow "Between" dfBetween ssBetween msBetween
                 (Just fStat) (Just pVal) (Just etaSq)
      , AnovaRow "Within"  dfWithin  ssWithin  msWithin
                 Nothing Nothing Nothing
      , AnovaRow "Total"   (n - 1)   ssTotal   (ssTotal / fromIntegral (n - 1))
                 Nothing Nothing Nothing
      ]

-- | Two-way ANOVA (no interaction term).
--
-- Each cell @(a, b)@ is assumed to hold exactly one observation, and
-- the data is assumed balanced (all cells have the same observation
-- count).
twoWayAnova :: [Text]   -- ^ Factor A label per observation.
            -> [Text]   -- ^ Factor B label per observation.
            -> [Double] -- ^ Values.
            -> AnovaTable
twoWayAnova as bs values =
  let n    = length values
      gm   = sum values / fromIntegral n
      ssT  = sum [(v - gm)^(2::Int) | v <- values]
      -- 因子 A の主効果
      aGroups = groupBy ((==) `on` fst) (sort (zip as values))
      ssA  = sum
        [ let vs = map snd g
              m  = sum vs / fromIntegral (length vs)
          in fromIntegral (length vs) * (m - gm)^(2::Int)
        | g <- aGroups ]
      -- 因子 B の主効果
      bGroups = groupBy ((==) `on` fst) (sort (zip bs values))
      ssB  = sum
        [ let vs = map snd g
              m  = sum vs / fromIntegral (length vs)
          in fromIntegral (length vs) * (m - gm)^(2::Int)
        | g <- bGroups ]
      ssE  = ssT - ssA - ssB
      a    = length aGroups
      b    = length bGroups
      dfA  = a - 1
      dfB  = b - 1
      dfE  = n - a - b + 1
      msA  = ssA / fromIntegral dfA
      msB  = ssB / fromIntegral dfB
      msE  = if dfE > 0 then ssE / fromIntegral dfE else 1
      fA   = msA / msE
      fB   = msB / msE
      pA   = if dfE <= 0 then 1
               else SD.complCumulative (FD.fDistribution dfA dfE) fA
      pB   = if dfE <= 0 then 1
               else SD.complCumulative (FD.fDistribution dfB dfE) fB
  in AnovaTable
      [ AnovaRow "Factor A" dfA ssA msA (Just fA) (Just pA) (Just (ssA/ssT))
      , AnovaRow "Factor B" dfB ssB msB (Just fB) (Just pB) (Just (ssB/ssT))
      , AnovaRow "Error"    dfE ssE msE Nothing Nothing Nothing
      , AnovaRow "Total"    (n - 1) ssT (ssT / fromIntegral (n - 1))
                 Nothing Nothing Nothing
      ]

-- | Pretty-print the table to stdout.
printAnovaTable :: AnovaTable -> IO ()
printAnovaTable (AnovaTable rows) = do
  printf "%-12s %4s %12s %12s %10s %10s %8s\n"
    ("Source" :: String) ("DF" :: String) ("SS" :: String) ("MS" :: String)
    ("F" :: String) ("p-value" :: String) ("η²" :: String)
  putStrLn (replicate 76 '-')
  mapM_ printRow rows
  where
    printRow r = do
      printf "%-12s %4d %12.4f %12.4f"
             (T.unpack (arSource r)) (arDF r) (arSS r) (arMS r)
      let fmtMaybe :: Double -> String
          fmtMaybe v = printf "%10.4f" v
      case arF r of
        Just f  -> putStr (fmtMaybe f)
        Nothing -> putStr (printf "%10s" ("--" :: String))
      case arPVal r of
        Just p  -> putStr (fmtMaybe p)
        Nothing -> putStr (printf "%10s" ("--" :: String))
      case arEtaSq r of
        Just e  -> printf "%8.4f\n" e
        Nothing -> printf "%8s\n" ("--" :: String)