packages feed

hanalyze-0.2.0.0: src/Hanalyze/Model/Spline.hs

{-# LANGUAGE OverloadedStrings #-}

-- |
-- Module      : Hanalyze.Model.Spline
-- Description : B-spline / 自然三次スプライン回帰 (Cox-de Boor 基底 + LM フィット)
-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
-- License     : BSD-3-Clause
--
-- B-spline and natural cubic-spline regression.
--
-- Builds a design matrix @B@ from spline basis functions and solves
-- ordinary least squares for the coefficients @β@:
--
-- @
-- y_i = Σ_j β_j B_j(x_i) + ε_i
-- @
--
--   * 'bsplineBasis'       — degree-@k@ B-spline basis via the Cox-de Boor
--     recursion.
--   * 'naturalSplineBasis' — natural cubic spline (linear outside the
--     boundary).
--   * 'fitSpline'          — fit using the basis matrix + LM.
--   * 'predictSpline'      — predict at new @x@ values.
module Hanalyze.Model.Spline
  ( SplineKind (..)
  , SplineFit (..)
  , SplineFitMulti (..)
  , bsplineBasis
  , naturalSplineBasis
  , fitSpline
  , fitSplineMulti
  , predictSpline
  , predictSplineMulti
  , equalSpacedKnots
  , quantileKnots
  ) where

import qualified Data.Vector as V
import qualified Numeric.LinearAlgebra as LA
import Data.List (sort)
import Hanalyze.Model.Core (FitResult (..))
import Hanalyze.Model.LM (fitLM)

-- | Spline kind.
data SplineKind
  = BSpline Int    -- ^ B-spline of degree @k@ (3 = cubic is typical).
  | NaturalCubic   -- ^ Natural cubic spline.
  deriving (Show, Eq)

-- | Spline fit result, with everything needed to reproduce predictions.
data SplineFit = SplineFit
  { sfKind   :: SplineKind
  , sfKnots  :: [Double]         -- ^ Interior knots (boundaries included).
  , sfBeta   :: LA.Vector Double -- ^ Basis-coefficient vector.
  , sfResult :: FitResult        -- ^ Underlying linear-model fit.
  } deriving (Show)

-- ---------------------------------------------------------------------------
-- B-spline basis (Cox-de Boor recursion)
-- ---------------------------------------------------------------------------

-- | Evaluate every B-spline basis function at a single point.
--
-- Inputs: degree @k@, extended knot sequence @t@ (length
-- @n_basis + k + 1@), and the evaluation point @x@. Returns
-- @[B_0(x), B_1(x), ..., B_{n_basis-1}(x)]@.
bsplineEval :: Int -> [Double] -> Double -> [Double]
bsplineEval k tKnots x =
  let nBasis = length tKnots - k - 1
      -- Order 0 (= k=0): 1 if x in [t_i, t_{i+1}), else 0
      -- 端点処理: 右端 x == hi は **hi で終わる最後の正幅区間** [ti, hi) に含める。
      -- clamped ノットは hi を k+1 回重複させるため、 単純に「最後の区間 index を右閉」
      -- にすると退化区間 [hi, hi] を選んでしまい、 高次 Cox-de Boor 再帰で d2=0 となって
      -- 基底が全ゼロ化する (= partition of unity 崩壊。 計測で確認: x=hi で sum=0)。
      hiKnot = last tKnots
      order0 i =
        let ti  = tKnots !! i
            ti1 = tKnots !! (i + 1)
            atRightEnd = x >= ti1 && ti1 == hiKnot && ti < ti1
        in if (x >= ti && x < ti1) || atRightEnd
             then 1.0 else 0.0
      -- 高次: Cox-de Boor
      go p prev =
        let n_p = length prev - 1   -- prev の長さは n + p
        in [ let ti   = tKnots !! i
                 tipk = tKnots !! (i + p)
                 ti1  = tKnots !! (i + 1)
                 ti1pk = tKnots !! (i + p + 1)
                 d1   = tipk - ti
                 d2   = ti1pk - ti1
                 a    = if d1 == 0 then 0
                          else (x - ti) / d1 * (prev !! i)
                 b    = if d2 == 0 then 0
                          else (ti1pk - x) / d2 * (prev !! (i + 1))
             in a + b
           | i <- [0 .. n_p - 1] ]
      step p prev | p > k     = prev
                  | otherwise = step (p + 1) (go p prev)
      ord0 = [order0 i | i <- [0 .. length tKnots - 2]]
  in take nBasis (step 1 ord0)

-- | B-spline basis matrix.
--
-- Inputs:
--
--   * @k@        — degree (3 typical).
--   * @intKnots@ — interior knots (boundaries included; assumed sorted).
--   * @xs@       — evaluation points.
--
-- The output matrix has shape @n × n_basis@ where
-- @n_basis = length intKnots + k - 1@. The extended knot sequence is
-- built by replicating each boundary @k+1@ times (clamped B-spline).
bsplineBasis :: Int -> [Double] -> V.Vector Double -> LA.Matrix Double
bsplineBasis k intKnots xs =
  let knots = sort intKnots
      lo    = head knots
      hi    = last knots
      tExt  = replicate (k + 1) lo
              ++ tail (init knots)        -- 内部ノット
              ++ replicate (k + 1) hi
      -- 上で tExt の長さは (k+1) + (length knots - 2) + (k+1) = length knots + 2k
      -- n_basis = length knots + 2k - k - 1 = length knots + k - 1
      rows  = [ bsplineEval k tExt x | x <- V.toList xs ]
  in LA.fromLists rows

-- ---------------------------------------------------------------------------
-- Natural cubic spline basis
-- ---------------------------------------------------------------------------

-- | Natural cubic-spline basis (zero second derivative at the
-- boundaries; linear outside the boundary).
--
-- ノット K1 < K2 < ... < KN に対して、N 個の基底関数:
--   N_1(x) = 1
--   N_2(x) = x
--   N_{k+2}(x) = d_k(x) - d_{N-1}(x)  for k = 1..N-2
-- where
--   d_k(x) = [(x - K_k)_+^3 - (x - K_N)_+^3] / (K_N - K_k)
--
-- 出力: 行列 (n × N)。
naturalSplineBasis :: [Double] -> V.Vector Double -> LA.Matrix Double
naturalSplineBasis knots xs =
  let ks = sort knots
      n  = length ks
      kN = last ks
      kNm1 = ks !! (n - 2)
      pos3 v = if v <= 0 then 0 else v ^ (3 :: Int)
      d k x =
        let kk = ks !! k
        in (pos3 (x - kk) - pos3 (x - kN)) / (kN - kk)
      basis x =
        [1.0, x] ++
        [ d k x - d (n - 2) x | k <- [0 .. n - 3] ]
  in LA.fromLists [basis xv | xv <- V.toList xs]

-- ---------------------------------------------------------------------------
-- Fit / predict
-- ---------------------------------------------------------------------------

-- | Single-output spline regression. Delegates to 'fitSplineMulti' by
-- promoting @y@ to a one-column matrix.
fitSpline :: SplineKind -> [Double] -> V.Vector Double -> V.Vector Double -> SplineFit
fitSpline kind knots xs ys =
  let yMat = LA.asColumn (LA.fromList (V.toList ys))
      mf   = fitSplineMulti kind knots xs yMat
      beta = LA.flatten (smfBeta mf LA.¿ [0])
  in SplineFit kind knots beta (smfResult mf)

-- | Predict at new @x@ values from a 'SplineFit'.
predictSpline :: SplineFit -> V.Vector Double -> V.Vector Double
predictSpline fit xsNew =
  let dm = case sfKind fit of
        BSpline k     -> bsplineBasis k (sfKnots fit) xsNew
        NaturalCubic  -> naturalSplineBasis (sfKnots fit) xsNew
      yPred = dm LA.#> sfBeta fit
  in V.fromList (LA.toList yPred)

-- | Multi-output spline regression: fit @q@ outputs jointly on the same
-- @x@ grid. Internally a basis matrix plus a multi-output LM.
data SplineFitMulti = SplineFitMulti
  { smfKind   :: SplineKind
  , smfKnots  :: [Double]
  , smfBeta   :: LA.Matrix Double  -- ^ Basis coefficients (@basis_dim × q@).
  , smfResult :: FitResult
  } deriving (Show)

-- | Fit a multi-output spline. @Y@ has shape @n × q@; columns share the
-- basis but are otherwise fit independently.
fitSplineMulti :: SplineKind
               -> [Double]            -- ^ Knots.
               -> V.Vector Double     -- ^ Inputs @xs@ (length @n@).
               -> LA.Matrix Double    -- ^ Response @Y@ (@n × q@).
               -> SplineFitMulti
fitSplineMulti kind knots xs ys =
  let dm = case kind of
        BSpline k     -> bsplineBasis k knots xs
        NaturalCubic  -> naturalSplineBasis knots xs
      r  = fitLM dm ys
  in SplineFitMulti kind knots (coefficients r) r

-- | Predict @Ŷ@ at new inputs from a 'SplineFitMulti'.
predictSplineMulti :: SplineFitMulti -> V.Vector Double -> LA.Matrix Double
predictSplineMulti fit xsNew =
  let dm = case smfKind fit of
        BSpline k     -> bsplineBasis k (smfKnots fit) xsNew
        NaturalCubic  -> naturalSplineBasis (smfKnots fit) xsNew
  in dm LA.<> smfBeta fit

-- ---------------------------------------------------------------------------
-- Knot helpers
-- ---------------------------------------------------------------------------

-- | Equal-spaced knots (both endpoints included, @n@ points total).
equalSpacedKnots :: Int -> Double -> Double -> [Double]
equalSpacedKnots n lo hi
  | n < 2     = [lo, hi]
  | otherwise = [lo + fromIntegral i * (hi - lo) / fromIntegral (n - 1)
                | i <- [0 .. n - 1]]

-- | Quantile-based knots (boundaries at min/max, interior knots at
-- evenly-spaced sample quantiles).
quantileKnots :: Int -> V.Vector Double -> [Double]
quantileKnots n xs
  | n < 2     = [V.minimum xs, V.maximum xs]
  | otherwise =
      let sorted = sort (V.toList xs)
          m      = length sorted
          qAt p  = sorted !! min (m - 1) (max 0 (floor (p * fromIntegral m) :: Int))
          ps     = [fromIntegral i / fromIntegral (n - 1) | i <- [0 .. n - 1] :: [Int]]
      in map qAt ps