packages feed

hanalyze-0.2.0.0: test/Hanalyze/Model/LM/DiagnosticsSpec.hs

{-# OPTIONS_GHC -Wno-unused-imports #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeApplications #-}
module Hanalyze.Model.LM.DiagnosticsSpec (spec) where

import Test.Hspec
import Test.Hspec.QuickCheck (prop)
import Test.QuickCheck
import Hanalyze.Model.Formula
import Hanalyze.Model.Formula.Frame
import Hanalyze.Model.Formula.Design
import Hanalyze.Model.Formula.RFormula
import Hanalyze.Model.Formula.Nonlinear
import Hanalyze.Model.Formula.Mixed
import Hanalyze.Model.GLMM
import Hanalyze.Model.GLM (Family (..), LinkFn (..))
import Hanalyze.Stat.Distribution (Transform)
import Data.List (sort, nub)
import Control.Monad (forM, forM_)
import System.IO.Temp (withSystemTempFile)
import System.IO     (hPutStr, hClose)
import           Hanalyze.Model.HBM.Ast (Expr (..), Lit (..), DoStmt (..), Err)
import           Data.IORef         (newIORef, readIORef, modifyIORef')
import qualified Numeric.LinearAlgebra as LA
import qualified Hanalyze.Model.LM             as LM
import qualified Hanalyze.Model.LM.Diagnostics as LMD
import SpecHelper

spec :: Spec
spec = do
  -- Phase 82: 飽和 fit (df = n−p <= 0) で CI/PI 帯が Student-t 例外を出さず、
  -- 幅ゼロ帯 (lo=hi=ŷ) に潰れること。 profiler が飽和モデルで落ちないための保証。
  describe "LM CI/PI band: 飽和 (df<=0) ガード (Phase 82)" $ do
    let xSat = LA.fromColumns [ LA.konst 1.0 2, LA.fromList [1.0, 2.0] ]  -- 2×2 = 2 param
        ySat = LA.fromList [3.0, 5.0]
        fitS = LM.fitLMVec xSat ySat                                     -- df = 2 − 2 = 0
    it "confidenceBandAt: df=0 は例外なし・幅ゼロ (lo=hi)" $ do
      let b = LM.confidenceBandAt xSat fitS 0.95 xSat
      LM.lowerBound b `shouldBe` LM.upperBound b
    it "predictionBandAt: df=0 は例外なし・幅ゼロ (lo=hi)" $ do
      let b = LM.predictionBandAt xSat fitS 0.95 xSat
      LM.lowerBound b `shouldBe` LM.upperBound b
    it "df>0 では通常どおり幅を持つ (回帰・帯が線でない)" $ do
      let x3 = LA.fromColumns [ LA.konst 1.0 3, LA.fromList [1.0, 2.0, 3.0] ]
          y3 = LA.fromList [3.0, 5.2, 6.9]
          b  = LM.confidenceBandAt x3 (LM.fitLMVec x3 y3) 0.95 x3
      LM.lowerBound b `shouldNotBe` LM.upperBound b

  describe "Hanalyze.Model.LM.Diagnostics (vs statsmodels OLS)" $ do
    let xRaw = [1.0, 2, 3, 4, 5, 6, 7, 8, 9, 10]
        yRaw = [3.7, 5.2, 6.8, 8.5, 9.9, 11.3, 13.1, 14.4, 15.8, 17.2]
        x    = LA.fromColumns
                 [ LA.konst 1.0 (length xRaw)
                 , LA.fromList xRaw
                 ]
        y    = LA.fromList yRaw
        fit  = LM.fitLMVec x y

        approx tol a b = abs (a - b) < tol
        approxV tol expected actual =
          length expected == LA.size actual &&
          and (zipWith (approx tol) expected (LA.toList actual))

    it "ciTValue: 95% / df=8 ≈ 2.306" $
      LMD.ciTValue 0.95 8 `shouldSatisfy` approx 1e-3 2.306

    it "lmStdErrors matches statsmodels (intercept, slope)" $
      LMD.lmStdErrors x fit `shouldSatisfy`
        approxV 1e-5 [0.09602188, 0.01547533]

    it "lmCoefStats t / p match statsmodels" $ do
      let cs = LMD.lmCoefStats x fit
      length cs `shouldBe` 2
      LMD.csTValue (head cs)    `shouldSatisfy` approx 1e-3 23.8834
      LMD.csTValue (cs !! 1)    `shouldSatisfy` approx 1e-3 97.4768
      LMD.csPValue (head cs)    `shouldSatisfy` approx 1e-9 1.0062e-8
      LMD.csPValue (cs !! 1)    `shouldSatisfy` approx 1e-13 1.3699e-13

    it "lmFStatistic matches statsmodels (F, p, df1, df2)" $ do
      let fs = head (LMD.lmFStatistic x fit)
      LMD.fsValue fs  `shouldSatisfy` approx 1e-1 9501.7193
      LMD.fsPValue fs `shouldSatisfy` approx 1e-13 1.3699e-13
      LMD.fsDf1 fs `shouldBe` 1
      LMD.fsDf2 fs `shouldBe` 8

    it "lmInformationCriteria (R lm() convention, k = p + 1 with σ)" $ do
      let ic = LMD.lmInformationCriteria fit
      LMD.icLogLik ic `shouldSatisfy` approx 1e-5 6.547424
      LMD.icAIC    ic `shouldSatisfy` approx 1e-5 (-7.094848)
      LMD.icBIC    ic `shouldSatisfy` approx 1e-5 (-6.187093)

    it "hatDiagonal matches statsmodels leverage" $
      LMD.hatDiagonal x `shouldSatisfy`
        approxV 1e-6
          [ 0.34545455, 0.24848485, 0.17575758, 0.12727273, 0.10303030
          , 0.10303030, 0.12727273, 0.17575758, 0.24848485, 0.34545455 ]

    it "standardizedResiduals match statsmodels (resid_studentized_internal)" $
      LMD.standardizedResiduals x fit `shouldSatisfy`
        approxV 1e-5
          [ -0.89534127, -0.90521501, -0.14722564,  1.31539084,  0.48257654
          , -0.33234045,  1.88308584,  0.30394970, -0.57197652, -1.56684722 ]

    it "cooksDistance matches statsmodels" $
      LMD.cooksDistance x fit `shouldSatisfy`
        approxV 1e-5
          [ 0.21154283, 0.13546767, 0.00231098, 0.12616429, 0.01337487
          , 0.00634342, 0.25856339, 0.00984992, 0.05408646, 0.64784992 ]

    it "predictorStdDevs: intercept col=0, x col≈3.0277" $ do
      let sds = LMD.predictorStdDevs x
      LA.size sds `shouldBe` 2
      (LA.toList sds !! 0) `shouldSatisfy` approx 1e-12 0
      (LA.toList sds !! 1) `shouldSatisfy` approx 1e-6 3.027650

  -- ─────────────────────────────────────────────────────────────────────