packages feed

hanalyze-0.2.0.0: src/Hanalyze/Model/Formula/Nonlinear.hs

{-# LANGUAGE OverloadedStrings #-}

-- |
-- Module      : Hanalyze.Model.Formula.Nonlinear
-- Description : Formula DSL の非線形最小二乗 (NLS) fit
-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
-- License     : BSD-3-Clause
--
-- Formula DSL — 非線形最小二乗 (NLS、 A4)。
--   現状 @a*exp(-b*x)@ のように **パラメータがデータ式の内側に現れる式** ('designMatrixF'
--   は線形でないとして 'Left') を、 parse 済 AST を評価関数化して既存の最適化器
--   ('Hanalyze.Optim.NelderMead') で SSR を最小化し fit する。
--
--   ★考え方: 線形 OLS と違い param 名が ŷ に効く。 @evalNL@ が「params 表 + ModelFrame」 から
--   右辺式を **行ごとの ŷ ベクトル** に評価する (param は定数、 連続データ変数は列ベクトル)。
--   目的関数 @SSR(θ) = Σ(y − ŷ(θ))²@ を Nelder-Mead で最小化。
--   ★初期値はユーザ必須 (NLS は初期値依存)。 factor 添字は非対応 (線形側で扱う)。
--   ★最適化器は IO を返すが決定論的ゆえ 'unsafePerformIO' で pure 化 (Convert.hs 同方針)。
--
--   plot 非依存・portable。
module Hanalyze.Model.Formula.Nonlinear
  ( NLSResult (..)
  , fitNLS
  , evalNL
  ) where

import           Data.Text               (Text)
import qualified Data.Text               as T
import qualified Data.Vector             as V
import           System.IO.Unsafe        (unsafePerformIO)

import           Hanalyze.Model.Formula  (BinOp (..), Formula (..), Term (..))
import           Hanalyze.Model.Formula.Frame
import           Hanalyze.Optim.Common    (OptimResult (..))
import           Hanalyze.Optim.NelderMead (runNelderMead)
import qualified DataFrame.Internal.DataFrame  as DX

-- | 非線形 fit の結果。
data NLSResult = NLSResult
  { nlsParams    :: [(Text, Double)]   -- ^ 推定パラメータ (名前つき)
  , nlsFitted    :: V.Vector Double    -- ^ ŷ
  , nlsResidual  :: V.Vector Double    -- ^ y − ŷ
  , nlsSSR       :: Double             -- ^ 残差平方和
  , nlsConverged :: Bool               -- ^ 最適化器が許容誤差で停止したか
  }
  deriving (Eq, Show)

-- | 右辺式を **行ごとの値ベクトル** に評価する。 params は表から定数、 連続データ変数は
--   ModelFrame の列、 factor / 応答は 'Left'。 (線形の 'evalData' と違い param を許す。)
evalNL :: [(Text, Double)] -> ModelFrame -> Term -> Either String (V.Vector Double)
evalNL pm mf = go
  where
    n = mfNRows mf
    go t = case t of
      Lit d -> Right (V.replicate n d)
      Ref x -> case lookup x (mfRoles mf) of
        Just (RoleContinuous v) -> Right v
        Just (RoleResponse _)   -> Left $ "応答 '" <> T.unpack x <> "' をデータ式に使えません"
        Just (RoleFactor _ _)   -> Left $ "非線形フィットは factor '" <> T.unpack x
                                           <> "' を扱えません"
        Nothing -> case lookup x pm of
          Just d  -> Right (V.replicate n d)
          Nothing -> Left $ "未知の変数 '" <> T.unpack x <> "'"
      Neg a -> V.map negate <$> go a
      App f [a] | Just fn <- lookup f unaryFns -> V.map fn <$> go a
      App f _   -> Left $ "未対応の関数 '" <> T.unpack f
                           <> "' (log/exp/sqrt/sin/cos/tan/abs の単項のみ)"
      Bin op a b -> V.zipWith (binFn op) <$> go a <*> go b
      Index _ _  -> Left "非線形フィットは factor 添字を扱えません"

unaryFns :: [(Text, Double -> Double)]
unaryFns =
  [ ("log", log), ("exp", exp), ("sqrt", sqrt)
  , ("sin", sin), ("cos", cos), ("tan", tan), ("abs", abs) ]

binFn :: BinOp -> (Double -> Double -> Double)
binFn Add = (+)
binFn Sub = (-)
binFn Mul = (*)
binFn Div = (/)
binFn Pow = (**)

-- | 非線形最小二乗。 @inits@ = 各パラメータの初期値 (mfParams を網羅する必要がある)。
--   SSR を Nelder-Mead で最小化する。 不正値 (NaN) を出すパラメータ域は +∞ で罰する。
fitNLS :: Formula -> DX.DataFrame -> [(Text, Double)] -> Either String NLSResult
fitNLS f@(Formula _ _ rhs) df inits = do
  mf <- modelFrame f df
  yv <- case mfRoles mf of
          ((_, RoleResponse v) : _) -> Right v
          _                         -> Left "ModelFrame に応答列がありません"
  let pnames  = map fst inits
      missing = filter (`notElem` pnames) (mfParams mf)
  if not (null missing)
    then Left $ "初期値が無いパラメータ: " <> show (map T.unpack missing)
    else do
      _ <- evalNL inits mf rhs                       -- 評価可能性を先に検証
      let sse yhat = V.sum (V.map (\e -> e * e) (V.zipWith (-) yv yhat))
          ssrAt vals = case evalNL (zip pnames vals) mf rhs of
                         Right yhat -> let s = sse yhat in if isNaN s then 1 / 0 else s
                         Left _     -> 1 / 0
          res  = unsafePerformIO (runNelderMead ssrAt (map snd inits))
          pm   = zip pnames (orBest res)
      yhat <- evalNL pm mf rhs
      let resid = V.zipWith (-) yv yhat
      Right NLSResult
        { nlsParams    = pm
        , nlsFitted    = yhat
        , nlsResidual  = resid
        , nlsSSR       = sse yhat
        , nlsConverged = orConverged res
        }