packages feed

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

{-# LANGUAGE OverloadedStrings #-}
-- |
-- Module      : Hanalyze.Model.PCA
-- Description : Principal Component Analysis (PCA) and related dimensionality reduction
-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
-- License     : BSD-3-Clause
--
-- Principal Component Analysis (PCA) and related dimensionality
-- reduction.
--
-- @
-- import Hanalyze.Model.PCA
--
-- let pcaRes = pca True x  -- center + scale
--     loadings = pcaComponents pcaRes
--     scores   = pcaTransform pcaRes x  -- project x onto components
-- @
--
-- * 'pca' fits PCA to a centred (and optionally scaled) feature matrix.
-- * 'pcaTransform' projects new data onto the learned components.
-- * 'pcaInverse' reconstructs from scores back to feature space.
-- * @screePlot@ / @biplot@ integration via @Viz@ (separate module).
module Hanalyze.Model.PCA
  ( -- * PCA
    PCAResult (..)
  , PCAStandardize (..)
  , pca
  , pcaTransform
  , pcaInverse
  , pcaCumExplained
    -- * Helpers
  , standardizeFeatures
  ) where

import qualified Numeric.LinearAlgebra as LA

-- | Standardisation mode for input features before SVD.
data PCAStandardize
  = NoStandardize
    -- ^ Do not center or scale (only useful when columns already have
    --   zero mean and comparable units).
  | Center
    -- ^ Subtract column means (default behaviour for PCA).
  | CenterScale
    -- ^ Subtract means and divide by sample standard deviations
    --   (= standardised PCA, AKA correlation-matrix PCA).
  deriving (Show, Eq)

-- | Result of fitting PCA. All matrices share the same number of
-- components @k@; if the user passed @k = Nothing@ then
-- @k = min(n, p)@.
data PCAResult = PCAResult
  { pcaMean       :: !(LA.Vector Double)
    -- ^ Per-column mean of the training data (length @p@).
  , pcaScale      :: !(LA.Vector Double)
    -- ^ Per-column standard deviation (length @p@). All ones when
    --   'pcaStandardize' is 'NoStandardize' / 'Center'.
  , pcaStandardize :: !PCAStandardize
  , pcaComponents :: !(LA.Matrix Double)
    -- ^ Principal axes (@loadings@), shape @k × p@. Rows are unit
    --   vectors; PC@i@ corresponds to row @i@.
  , pcaSingularValues :: !(LA.Vector Double)
    -- ^ Singular values @σ_i@, length @k@. Sorted descending.
  , pcaExplainedVar :: !(LA.Vector Double)
    -- ^ Variance of each component (= σ_i² / (n − 1)). Length @k@.
  , pcaExplainedRatio :: !(LA.Vector Double)
    -- ^ Fraction of total variance explained by each component, length
    --   @k@. Sums to ≤ 1; equals 1 when k = rank(X).
  , pcaNSamples   :: !Int
  , pcaNFeatures  :: !Int
  } deriving (Show)

-- | Center (and optionally scale) a feature matrix. Returns the
-- transformed matrix along with the column means and per-column
-- standard deviations.
standardizeFeatures
  :: PCAStandardize
  -> LA.Matrix Double  -- ^ X (n × p)
  -> (LA.Matrix Double, LA.Vector Double, LA.Vector Double)
       -- ^ (Z, μ, σ).
standardizeFeatures std x =
  let n    = LA.rows x
      p    = LA.cols x
      ones = LA.konst 1 n :: LA.Vector Double
      mu   = LA.scale (1 / fromIntegral n) (ones LA.<# x)
      xC   = x - LA.fromRows (replicate n mu)
  in case std of
       NoStandardize ->
         (x, LA.konst 0 p, LA.konst 1 p)
       Center ->
         (xC, mu, LA.konst 1 p)
       CenterScale ->
         let sd2 = LA.scale (1 / fromIntegral (n - 1))
                     (LA.konst 1 n LA.<# (xC * xC))
             sd  = LA.cmap (\v -> if v < 1e-12 then 1 else sqrt v) sd2
             z   = xC LA.<> LA.diag (LA.cmap (1 /) sd)
         in (z, mu, sd)

-- | Fit PCA on a feature matrix.
--
-- Internally uses thin SVD on the (centred / scaled) matrix so the
-- cost is @O(min(n²p, np²))@. The first @k@ rows of @Vᵀ@ are the
-- principal axes; the singular values @σ@ give component magnitudes.
pca
  :: PCAStandardize
  -> Maybe Int          -- ^ k (number of components to keep). Nothing = all.
  -> LA.Matrix Double   -- ^ X (n × p)
  -> PCAResult
pca std mK x =
  let (z, mu, sd) = standardizeFeatures std x
      n           = LA.rows z
      p           = LA.cols z
      -- Thin SVD: z = U S Vᵀ, where U is n×r, S is r-vector, V is p×r.
      (u, s, vT)  = LA.thinSVD z
      _           = u
      kMax        = min (LA.rows z) (LA.cols z)
      k           = min kMax (maybe kMax id mK)
      -- Keep first k components.
      sK          = LA.subVector 0 k s
      -- 'thinSVD' returns Vᵀ as p × min(n,p); we want first k rows of
      -- Vᵀ (= first k columns of V transposed).
      vTk         = vT LA.?? (LA.All, LA.Take k)
      components  = LA.tr vTk            -- k × p
      -- Variance per component = σ² / (n − 1).
      varK        = LA.cmap (\sv -> sv * sv / fromIntegral (max 1 (n - 1))) sK
      totalVar    = LA.sumElements
                      (LA.cmap (\sv -> sv * sv / fromIntegral (max 1 (n - 1))) s)
      ratio       = if totalVar > 0
                      then LA.scale (1 / totalVar) varK
                      else LA.konst 0 k
  in PCAResult
       { pcaMean           = mu
       , pcaScale          = sd
       , pcaStandardize    = std
       , pcaComponents     = components
       , pcaSingularValues = sK
       , pcaExplainedVar   = varK
       , pcaExplainedRatio = ratio
       , pcaNSamples       = n
       , pcaNFeatures      = p
       }

-- | Project new data onto the learned principal components.
-- Returns scores of shape @m × k@ where @m@ is the number of new
-- samples.
pcaTransform :: PCAResult -> LA.Matrix Double -> LA.Matrix Double
pcaTransform r x =
  let m  = LA.rows x
      mu = pcaMean r
      sd = pcaScale r
      xC = x - LA.fromRows (replicate m mu)
      z  = case pcaStandardize r of
             NoStandardize -> x
             Center        -> xC
             CenterScale   -> xC LA.<> LA.diag (LA.cmap (1 /) sd)
  in z LA.<> LA.tr (pcaComponents r)        -- m × k

-- | Reconstruct from scores back to feature space (approximation when
-- not all components are kept). Inverse of 'pcaTransform' modulo
-- truncation error.
pcaInverse :: PCAResult -> LA.Matrix Double -> LA.Matrix Double
pcaInverse r scores =
  let m       = LA.rows scores
      mu      = pcaMean r
      sd      = pcaScale r
      zRecon  = scores LA.<> pcaComponents r          -- m × p
      xRecon  = case pcaStandardize r of
        NoStandardize -> zRecon
        Center        -> zRecon + LA.fromRows (replicate m mu)
        CenterScale   ->
          let unscaled = zRecon LA.<> LA.diag sd
          in unscaled + LA.fromRows (replicate m mu)
  in xRecon

-- | Cumulative explained variance ratio (length k).
pcaCumExplained :: PCAResult -> LA.Vector Double
pcaCumExplained r =
  let ratio = LA.toList (pcaExplainedRatio r)
      cum   = scanl1 (+) ratio
  in LA.fromList cum