hanalyze-models (empty) → 0.2.0.1
raw patch · 70 files changed
+22507/−0 lines, 70 filesdep +basedep +containersdep +dataframe-core
Dependencies added: base, containers, dataframe-core, hanalyze-bayes, hanalyze-core, hanalyze-frame, hmatrix, megaparsec, mwc-random, parser-combinators, primitive, statistics, text, vector, vector-algorithms
Files
- README.ja.md +143/−0
- README.md +146/−0
- hanalyze-models.cabal +125/−0
- src/Hanalyze/Model/AFT.hs +253/−0
- src/Hanalyze/Model/Cluster.hs +427/−0
- src/Hanalyze/Model/CompetingRisks.hs +114/−0
- src/Hanalyze/Model/DAG.hs +295/−0
- src/Hanalyze/Model/DecisionTree.hs +552/−0
- src/Hanalyze/Model/Discriminant.hs +256/−0
- src/Hanalyze/Model/FDA.hs +299/−0
- src/Hanalyze/Model/FitYByX.hs +135/−0
- src/Hanalyze/Model/Formula.hs +272/−0
- src/Hanalyze/Model/Formula/Design.hs +559/−0
- src/Hanalyze/Model/Formula/Frame.hs +303/−0
- src/Hanalyze/Model/Formula/Mixed.hs +292/−0
- src/Hanalyze/Model/Formula/Nonlinear.hs +140/−0
- src/Hanalyze/Model/Formula/RFormula.hs +324/−0
- src/Hanalyze/Model/GAM.hs +387/−0
- src/Hanalyze/Model/GARCH.hs +222/−0
- src/Hanalyze/Model/GLM.hs +719/−0
- src/Hanalyze/Model/GLMM.hs +823/−0
- src/Hanalyze/Model/GP.hs +938/−0
- src/Hanalyze/Model/GPRobust.hs +395/−0
- src/Hanalyze/Model/GradientBoosting.hs +207/−0
- src/Hanalyze/Model/HierarchicalCluster.hs +245/−0
- src/Hanalyze/Model/KNN.hs +147/−0
- src/Hanalyze/Model/Kernel.hs +325/−0
- src/Hanalyze/Model/KernelRegression.hs +507/−0
- src/Hanalyze/Model/LM.hs +314/−0
- src/Hanalyze/Model/LM/Diagnostics.hs +278/−0
- src/Hanalyze/Model/LatentClassAnalysis.hs +212/−0
- src/Hanalyze/Model/LiNGAM/Bootstrap.hs +242/−0
- src/Hanalyze/Model/LiNGAM/Direct.hs +396/−0
- src/Hanalyze/Model/LiNGAM/ICA.hs +317/−0
- src/Hanalyze/Model/LiNGAM/MultiGroup.hs +238/−0
- src/Hanalyze/Model/LiNGAM/Pairwise.hs +139/−0
- src/Hanalyze/Model/LiNGAM/Parce.hs +309/−0
- src/Hanalyze/Model/LiNGAM/VAR.hs +141/−0
- src/Hanalyze/Model/MDS.hs +142/−0
- src/Hanalyze/Model/MultiGP.hs +325/−0
- src/Hanalyze/Model/MultiLM.hs +82/−0
- src/Hanalyze/Model/MultiOutput.hs +85/−0
- src/Hanalyze/Model/Multivariate.hs +186/−0
- src/Hanalyze/Model/NaiveBayes.hs +182/−0
- src/Hanalyze/Model/NeuralNetwork.hs +543/−0
- src/Hanalyze/Model/PCA.hs +180/−0
- src/Hanalyze/Model/PLS.hs +440/−0
- src/Hanalyze/Model/PartialDependence.hs +157/−0
- src/Hanalyze/Model/Quantile.hs +184/−0
- src/Hanalyze/Model/RFF.hs +1193/−0
- src/Hanalyze/Model/RandomForest.hs +471/−0
- src/Hanalyze/Model/RandomForestClassifier.hs +259/−0
- src/Hanalyze/Model/Regularized.hs +764/−0
- src/Hanalyze/Model/RegularizedAdvanced.hs +376/−0
- src/Hanalyze/Model/Reliability.hs +332/−0
- src/Hanalyze/Model/ReliabilityBlockDiagram.hs +88/−0
- src/Hanalyze/Model/Robust.hs +304/−0
- src/Hanalyze/Model/SVM.hs +359/−0
- src/Hanalyze/Model/Spline.hs +241/−0
- src/Hanalyze/Model/StateSpace.hs +160/−0
- src/Hanalyze/Model/Survival.hs +431/−0
- src/Hanalyze/Model/TimeSeries.hs +482/−0
- src/Hanalyze/Model/VAR.hs +134/−0
- src/Hanalyze/Model/Weibull.hs +394/−0
- src/Hanalyze/Optim/BayesOpt.hs +800/−0
- src/Hanalyze/Stat/Causal/CATE.hs +224/−0
- src/Hanalyze/Stat/Causal/DoublyRobust.hs +127/−0
- src/Hanalyze/Stat/Causal/IPW.hs +135/−0
- src/Hanalyze/Stat/Causal/PropensityScore.hs +120/−0
- src/Hanalyze/Stat/ModelSelect.hs +471/−0
+ README.ja.md view
@@ -0,0 +1,143 @@+# hanalyze-models++[`hanalyze`](../README.ja.md) の**モデル層**。 古典的な回帰から+機械学習・多変量解析・時系列・生存/信頼性・因果推論までの **model zoo** を+担う、 分割 6 層で最も大きい package (67 module)。++依存は `core` (数値基盤) / `frame` (dataframe interop) / `bayes` (MCMC) の+3 層 + 外部 12 package。 `-design` (DoE) と `-viz` はこの層の上に乗る。+Formula DSL のパーサを持つため `megaparsec` に依存するのはこの層だけ。++## 主要 module (全 67 module)++### 回帰の基本 (`Hanalyze.Model.*`)++| Module | 役割 |+|---|---|+| `Model.LM` / `Model.LM.Diagnostics` | 最小二乗回帰と残差診断 (てこ比 / Cook 距離 / VIF) |+| `Model.GLM` / `Model.GLMM` | IRLS による GLM (Gaussian / Binomial / Poisson を統一) と混合効果 GLM |+| `Model.MultiLM` | 多出力線形回帰 |+| `Model.Regularized` / `Model.RegularizedAdvanced` | Lasso / Ridge / Elastic Net + MCP / SCAD / Adaptive / Group。 λ は k-fold CV + 1-SE ルールで自動選択 |+| `Model.Robust` / `Model.Quantile` | M 推定によるロバスト回帰 / 分位点回帰 |+| `Model.Spline` / `Model.GAM` | スプライン平滑化 / 一般化加法モデル |++### Formula DSL (`Model.Formula.*`)++| Module | 役割 |+|---|---|+| `Model.Formula` | **Formula DSL 正本 front-end** の parser と AST (`y ~ x1 + x2*x3`) |+| `Model.Formula.Design` / `.Frame` | AST → model matrix / dataframe との結合 |+| `Model.Formula.Mixed` / `.Nonlinear` / `.RFormula` | 混合効果 / 非線形項 / R 互換 formula の解釈 |++### 多変量・次元圧縮++| Module | 役割 |+|---|---|+| `Model.PCA` | 主成分分析 (応答を使わない分散最大化) |+| `Model.PLS` | 部分最小二乗回帰 — 応答との共分散を最大化 + VIP + CV による成分数選択 |+| `Model.Discriminant` | 判別分析 (LDA = 線形境界 / QDA = 二次境界) |+| `Model.Multivariate` | RRR / PLS / CCA の多変量回帰族 |+| `Model.MDS` / `Model.FDA` | 多次元尺度構成法 / 関数データ解析 |+| `Model.Cluster` / `Model.HierarchicalCluster` / `Model.LatentClassAnalysis` | k-means / 階層クラスタリング / 潜在クラス分析 |++### 機械学習++| Module | 役割 |+|---|---|+| `Model.RandomForest` / `Model.RandomForestClassifier` | ランダムフォレスト (回帰 / 分類) |+| `Model.DecisionTree` / `Model.GradientBoosting` | 決定木 / 勾配ブースティング |+| `Model.SVM` / `Model.KNN` / `Model.NaiveBayes` / `Model.NeuralNetwork` | SVM / k 近傍 / 単純ベイズ / NN |+| `Model.Kernel` / `Model.KernelRegression` | カーネル関数群 / カーネル回帰 |+| `Model.PartialDependence` | PDP / ICE によるモデル解釈 |++### ガウス過程・多出力++| Module | 役割 |+|---|---|+| `Model.GP` / `Model.GPRobust` | GP 回帰 (RBF / Matérn / Periodic + ARD) / 外れ値に強い GP |+| `Model.MultiGP` / `Model.MultiOutput` | 多出力 GP / 多出力回帰の統合 API |+| `Model.RFF` | Random Fourier Features による大規模 GP 近似 |++### 時系列++| Module | 役割 |+|---|---|+| `Model.TimeSeries` | ARIMA 系の入口 |+| `Model.VAR` / `Model.GARCH` / `Model.StateSpace` | ベクトル自己回帰 / GARCH / 状態空間モデル (カルマンフィルタ) |++### 生存時間・信頼性++| Module | 役割 |+|---|---|+| `Model.Survival` | Kaplan-Meier / Cox 比例ハザード |+| `Model.AFT` / `Model.CompetingRisks` | 加速故障時間モデル / 競合リスク (CIF) |+| `Model.Weibull` | Weibull MLE (打ち切り対応) + B_p 寿命 + Wald 信頼区間 |+| `Model.Reliability` | 加速寿命試験 (Arrhenius / Eyring / Inverse Power Law) |+| `Model.ReliabilityBlockDiagram` | 直列 / 並列 / k-of-n の系統信頼度 |++### 因果推論++| Module | 役割 |+|---|---|+| `Model.LiNGAM.Direct` / `.ICA` / `.Pairwise` / `.Parce` | 非ガウス性を使った構造推定 (DirectLiNGAM / ICA-LiNGAM 等) |+| `Model.LiNGAM.VAR` / `.Bootstrap` / `.MultiGroup` | 時系列版 / ブートストラップ信頼度 / 多群同時推定 |+| `Model.DAG` | DAG の表現と探索 |+| `Stat.Causal.PropensityScore` / `.IPW` / `.DoublyRobust` / `.CATE` | 傾向スコア / IPW / 二重ロバスト推定 / 条件付き平均処置効果 |++### その他++| Module | 役割 |+|---|---|+| `Model.FitYByX` | JMP の "Fit Y by X" 相当 — 変数の型の組合せから手法を自動選択 |+| `Stat.ModelSelect` | AIC / BIC によるモデル選択 |+| `Optim.BayesOpt` | ベイズ最適化 (GP + 獲得関数)。 GP を使うためこの層に置く |++## 単体で使う++umbrella が不要なら、 この package を直接依存に書ける。 結果型+`FitResult` は core 層にあるため、 **`hanalyze-core` も明示的に要る**:++```cabal+build-depends: hanalyze-models, hanalyze-core, hmatrix+```++```haskell+import qualified Numeric.LinearAlgebra as LA+import Hanalyze.Model.Core (FitResult (..))+import Hanalyze.Model.LM (fitLM)++main :: IO ()+main = do+ let x = LA.fromLists [ [1, 1.0], [1, 2.0], [1, 3.0], [1, 4.0], [1, 5.0] ]+ y = LA.fromLists [ [2.1], [3.9], [6.2], [7.8], [10.1] ]+ fit = fitLM x y+ print (LA.toLists (coefficients fit))+ print (LA.toList (rSquared fit))+ -- [[5.000000000000132e-2],[1.9899999999999998]]+ -- [0.9973053289009771]+```++切片列 (`1`) は自分で入れる。 formula から model matrix を作るなら+`Model.Formula` を使う。++なお、 通常は umbrella package `hanalyze` を依存に書けば+`import Hanalyze` だけで上記もすべて使える。 層を直接指定するのは+依存を最小化したいときのみで十分。++## 関連 docs++- 線形回帰: [docs/regression/01-lm.ja.md](../docs/regression/01-lm.ja.md) /+ GLM: [02-glm.ja.md](../docs/regression/02-glm.ja.md)+- 罰則付き回帰: [04-regularized.ja.md](../docs/regression/04-regularized.ja.md) /+ [usage-regularized-advanced.ja.md](../docs/regression/usage-regularized-advanced.ja.md)+- PLS: [usage-pls.ja.md](../docs/regression/usage-pls.ja.md) /+ 判別分析 (LDA·QDA): [usage-discriminant.ja.md](../docs/regression/usage-discriminant.ja.md)+- Weibull / B_p 寿命: [usage-weibull.ja.md](../docs/regression/usage-weibull.ja.md) /+ 加速寿命試験: [usage-reliability.ja.md](../docs/regression/usage-reliability.ja.md)+- 生存時間解析: [10-survival.ja.md](../docs/regression/10-survival.ja.md) /+ 多出力: [05-multivariate.ja.md](../docs/regression/05-multivariate.ja.md)+- Formula DSL: [11-formula-dsl.ja.md](../docs/regression/11-formula-dsl.ja.md)+- 因果推論: [docs/causal/](../docs/causal/) /+ ベイズ最適化: [docs/optim/01-singleobj.ja.md](../docs/optim/01-singleobj.ja.md)++← [repository README](../README.ja.md)
+ README.md view
@@ -0,0 +1,146 @@+# hanalyze-models++The **model layer** of [`hanalyze`](../README.md). It owns the+**model zoo** — everything from classical regression through machine+learning, multivariate analysis, time series, survival/reliability, and+causal inference. It is the largest of the six split layers (67 modules).++It depends on the three layers `core` (numerical foundation) / `frame`+(dataframe interop) / `bayes` (MCMC), plus 12 external packages. `-design`+(DoE) and `-viz` sit on top of this layer. It is the only layer that depends+on `megaparsec`, since it owns the Formula DSL parser.++## Main modules (67 in total)++### Basic regression (`Hanalyze.Model.*`)++| Module | Role |+|---|---|+| `Model.LM` / `Model.LM.Diagnostics` | Least-squares regression and residual diagnostics (leverage / Cook's distance / VIF) |+| `Model.GLM` / `Model.GLMM` | GLM by IRLS (unifying Gaussian / Binomial / Poisson) and mixed-effects GLM |+| `Model.MultiLM` | Multi-output linear regression |+| `Model.Regularized` / `Model.RegularizedAdvanced` | Lasso / Ridge / Elastic Net + MCP / SCAD / Adaptive / Group. λ is auto-selected via k-fold CV plus the 1-SE rule |+| `Model.Robust` / `Model.Quantile` | Robust regression via M-estimation / quantile regression |+| `Model.Spline` / `Model.GAM` | Spline smoothing / generalized additive models |++### Formula DSL (`Model.Formula.*`)++| Module | Role |+|---|---|+| `Model.Formula` | Parser and AST for the **canonical Formula DSL front-end** (`y ~ x1 + x2*x3`) |+| `Model.Formula.Design` / `.Frame` | AST → model matrix, and joining with a dataframe |+| `Model.Formula.Mixed` / `.Nonlinear` / `.RFormula` | Interpretation of mixed-effects / nonlinear terms / R-compatible formulas |++### Multivariate & dimensionality reduction++| Module | Role |+|---|---|+| `Model.PCA` | Principal component analysis (variance maximization, no response) |+| `Model.PLS` | Partial least squares — maximizes covariance with the response, plus VIP and CV-based component selection |+| `Model.Discriminant` | Discriminant analysis (LDA = linear boundary / QDA = quadratic boundary) |+| `Model.Multivariate` | The RRR / PLS / CCA family of multivariate regression |+| `Model.MDS` / `Model.FDA` | Multidimensional scaling / functional data analysis |+| `Model.Cluster` / `Model.HierarchicalCluster` / `Model.LatentClassAnalysis` | k-means / hierarchical clustering / latent class analysis |++### Machine learning++| Module | Role |+|---|---|+| `Model.RandomForest` / `Model.RandomForestClassifier` | Random forest (regression / classification) |+| `Model.DecisionTree` / `Model.GradientBoosting` | Decision trees / gradient boosting |+| `Model.SVM` / `Model.KNN` / `Model.NaiveBayes` / `Model.NeuralNetwork` | SVM / k-nearest neighbors / naive Bayes / NN |+| `Model.Kernel` / `Model.KernelRegression` | Kernel function family / kernel regression |+| `Model.PartialDependence` | Model interpretation via PDP / ICE |++### Gaussian processes & multi-output++| Module | Role |+|---|---|+| `Model.GP` / `Model.GPRobust` | GP regression (RBF / Matérn / Periodic + ARD) / outlier-robust GP |+| `Model.MultiGP` / `Model.MultiOutput` | Multi-output GP / a unified API for multi-output regression |+| `Model.RFF` | Large-scale GP approximation via Random Fourier Features |++### Time series++| Module | Role |+|---|---|+| `Model.TimeSeries` | Entry point for the ARIMA family |+| `Model.VAR` / `Model.GARCH` / `Model.StateSpace` | Vector autoregression / GARCH / state-space models (Kalman filter) |++### Survival & reliability++| Module | Role |+|---|---|+| `Model.Survival` | Kaplan-Meier / Cox proportional hazards |+| `Model.AFT` / `Model.CompetingRisks` | Accelerated failure time models / competing risks (CIF) |+| `Model.Weibull` | Weibull MLE (with censoring support) + B_p life + Wald confidence intervals |+| `Model.Reliability` | Accelerated life testing (Arrhenius / Eyring / Inverse Power Law) |+| `Model.ReliabilityBlockDiagram` | System reliability for series / parallel / k-of-n configurations |++### Causal inference++| Module | Role |+|---|---|+| `Model.LiNGAM.Direct` / `.ICA` / `.Pairwise` / `.Parce` | Structure estimation using non-Gaussianity (DirectLiNGAM / ICA-LiNGAM, etc.) |+| `Model.LiNGAM.VAR` / `.Bootstrap` / `.MultiGroup` | Time-series variant / bootstrap confidence / simultaneous multi-group estimation |+| `Model.DAG` | DAG representation and search |+| `Stat.Causal.PropensityScore` / `.IPW` / `.DoublyRobust` / `.CATE` | Propensity score / IPW / doubly robust estimation / conditional average treatment effect |++### Miscellaneous++| Module | Role |+|---|---|+| `Model.FitYByX` | Equivalent of JMP's "Fit Y by X" — auto-selects a method from the combination of variable types |+| `Stat.ModelSelect` | Model selection via AIC / BIC |+| `Optim.BayesOpt` | Bayesian optimization (GP + acquisition function). Placed in this layer because it uses GP |++## Using it standalone++If you do not need the umbrella package, you can depend on this package+directly. Since the `FitResult` result type lives in the core layer, **you+also need `hanalyze-core` explicitly**:++```cabal+build-depends: hanalyze-models, hanalyze-core, hmatrix+```++```haskell+import qualified Numeric.LinearAlgebra as LA+import Hanalyze.Model.Core (FitResult (..))+import Hanalyze.Model.LM (fitLM)++main :: IO ()+main = do+ let x = LA.fromLists [ [1, 1.0], [1, 2.0], [1, 3.0], [1, 4.0], [1, 5.0] ]+ y = LA.fromLists [ [2.1], [3.9], [6.2], [7.8], [10.1] ]+ fit = fitLM x y+ print (LA.toLists (coefficients fit))+ print (LA.toList (rSquared fit))+ -- [[5.000000000000132e-2],[1.9899999999999998]]+ -- [0.9973053289009771]+```++You must add the intercept column (`1`) yourself. To build a model matrix+from a formula, use `Model.Formula`.++Normally you would just depend on the umbrella package `hanalyze` and+get all of the above from a single `import Hanalyze`. Naming a layer+directly is only worth it when you want to minimize dependencies.++## Related docs++- Linear regression: [docs/regression/01-lm.md](../docs/regression/01-lm.md) /+ GLM: [02-glm.md](../docs/regression/02-glm.md)+- Regularized regression: [04-regularized.md](../docs/regression/04-regularized.md) /+ [usage-regularized-advanced.md](../docs/regression/usage-regularized-advanced.md)+- PLS: [usage-pls.md](../docs/regression/usage-pls.md) /+ Discriminant analysis (LDA/QDA): [usage-discriminant.md](../docs/regression/usage-discriminant.md)+- Weibull / B_p life: [usage-weibull.md](../docs/regression/usage-weibull.md) /+ Accelerated life testing: [usage-reliability.md](../docs/regression/usage-reliability.md)+- Survival analysis: [10-survival.md](../docs/regression/10-survival.md) /+ Multi-output: [05-multivariate.md](../docs/regression/05-multivariate.md)+- Formula DSL: [11-formula-dsl.md](../docs/regression/11-formula-dsl.md)+- Causal inference: [docs/causal/](../docs/causal/) /+ Bayesian optimization: [docs/optim/01-singleobj.md](../docs/optim/01-singleobj.md)++← [repository README](../README.md)
+ hanalyze-models.cabal view
@@ -0,0 +1,125 @@+cabal-version: 3.0+name: hanalyze-models+version: 0.2.0.1+synopsis: Model layer of hanalyze: regression, ML, survival, causal+description:+ The model layer of the hanalyze toolkit: a model zoo spanning+ classical regression (LM, GLM, GLMM, robust, quantile, splines, GAM),+ penalised regression with automatic lambda selection (Lasso / Ridge /+ Elastic Net / MCP / SCAD), the Formula DSL front-end, multivariate+ analysis (PCA, PLS, LDA / QDA, MDS, clustering), machine learning+ (random forest, gradient boosting, SVM, k-NN, neural networks),+ Gaussian processes and multi-output models, time series (VAR, GARCH,+ state space), survival and reliability (Kaplan-Meier, Cox, AFT,+ Weibull MLE, accelerated-life models), causal inference (LiNGAM,+ propensity score, IPW, doubly robust, CATE) and Bayesian optimisation.+ .+ Module names match the umbrella package hanalyze, which re-exports+ everything, so downstream imports stay identical. See README.md for the+ module map and a standalone usage example.+license: BSD-3-Clause+author: Toshiaki Honda+maintainer: frenzieddoll@gmail.com+copyright: 2026 Aelysce Project (Toshiaki Honda)+category: Math, Statistics, Numeric, Machine Learning+build-type: Simple+tested-with: GHC == 9.6.7+extra-source-files:+ README.md+ README.ja.md++common warnings+ ghc-options: -Wall -Wcompat -Widentities -Wredundant-constraints++-- -O2 は分割前と同一 (性能変更と構造変更を混ぜない、 層別 -O 調整は 106.5 後の別 Phase)+common opt+ ghc-options: -O2 -funbox-strict-fields++library+ import: warnings, opt+ hs-source-dirs: src+ default-language: GHC2021+ exposed-modules:+ Hanalyze.Model.AFT+ Hanalyze.Model.Cluster+ Hanalyze.Model.CompetingRisks+ Hanalyze.Model.DAG+ Hanalyze.Model.DecisionTree+ Hanalyze.Model.Discriminant+ Hanalyze.Model.FDA+ Hanalyze.Model.FitYByX+ Hanalyze.Model.Formula+ Hanalyze.Model.Formula.Design+ Hanalyze.Model.Formula.Frame+ Hanalyze.Model.Formula.Mixed+ Hanalyze.Model.Formula.Nonlinear+ Hanalyze.Model.Formula.RFormula+ Hanalyze.Model.GAM+ Hanalyze.Model.GARCH+ Hanalyze.Model.GLM+ Hanalyze.Model.GLMM+ Hanalyze.Model.GP+ Hanalyze.Model.GPRobust+ Hanalyze.Model.GradientBoosting+ Hanalyze.Model.HierarchicalCluster+ Hanalyze.Model.KNN+ Hanalyze.Model.Kernel+ Hanalyze.Model.KernelRegression+ Hanalyze.Model.LM+ Hanalyze.Model.LM.Diagnostics+ Hanalyze.Model.LatentClassAnalysis+ Hanalyze.Model.LiNGAM.Bootstrap+ Hanalyze.Model.LiNGAM.Direct+ Hanalyze.Model.LiNGAM.ICA+ Hanalyze.Model.LiNGAM.MultiGroup+ Hanalyze.Model.LiNGAM.Pairwise+ Hanalyze.Model.LiNGAM.Parce+ Hanalyze.Model.LiNGAM.VAR+ Hanalyze.Model.MDS+ Hanalyze.Model.MultiGP+ Hanalyze.Model.MultiLM+ Hanalyze.Model.MultiOutput+ Hanalyze.Model.Multivariate+ Hanalyze.Model.NaiveBayes+ Hanalyze.Model.NeuralNetwork+ Hanalyze.Model.PCA+ Hanalyze.Model.PLS+ Hanalyze.Model.PartialDependence+ Hanalyze.Model.Quantile+ Hanalyze.Model.RFF+ Hanalyze.Model.RandomForest+ Hanalyze.Model.RandomForestClassifier+ Hanalyze.Model.Regularized+ Hanalyze.Model.RegularizedAdvanced+ Hanalyze.Model.Reliability+ Hanalyze.Model.ReliabilityBlockDiagram+ Hanalyze.Model.Robust+ Hanalyze.Model.SVM+ Hanalyze.Model.Spline+ Hanalyze.Model.StateSpace+ Hanalyze.Model.Survival+ Hanalyze.Model.TimeSeries+ Hanalyze.Model.VAR+ Hanalyze.Model.Weibull+ Hanalyze.Optim.BayesOpt+ Hanalyze.Stat.Causal.CATE+ Hanalyze.Stat.Causal.DoublyRobust+ Hanalyze.Stat.Causal.IPW+ Hanalyze.Stat.Causal.PropensityScore+ Hanalyze.Stat.ModelSelect+ build-depends:+ base >= 4.14 && < 5+ , containers >= 0.6 && < 0.8+ , hmatrix >= 0.20 && < 0.22+ , mwc-random >= 0.15 && < 0.16+ , primitive >= 0.7 && < 0.10+ , statistics >= 0.16 && < 0.17+ , text >= 1.2 && < 2.2+ , vector >= 0.12 && < 0.14+ , dataframe-core ^>= 1.1+ , vector-algorithms >= 0.9 && < 0.10+ , megaparsec >= 9.0 && < 9.7+ , parser-combinators >= 1.3 && < 1.4+ , hanalyze-core == 0.2.0.1+ , hanalyze-frame == 0.2.0.1+ , hanalyze-bayes == 0.2.0.1
+ src/Hanalyze/Model/AFT.hs view
@@ -0,0 +1,253 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE BangPatterns #-}+-- |+-- Module : Hanalyze.Model.AFT+-- Description : Accelerated Failure Time (AFT) パラメトリック生存モデル+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- [日本語]: Accelerated Failure Time (AFT) パラメトリック生存モデル。+--+-- AFT は寿命 T の対数を共変量の線形関数として表現する:+--+-- @+-- log T_i = X_i β + σ ε_i+-- @+--+-- ε の分布で family が決まる:+--+-- - 'AFTWeibull' : ε ~ Gumbel (生存解析の Weibull AFT)+-- - 'AFTLogNormal' : ε ~ Normal(0, 1)+-- - 'AFTLogLogistic': ε ~ Logistic(0, 1)+-- - 'AFTExponential': Weibull with σ = 1 を固定+--+-- 右側打ち切り (right censoring) 対応。 推定は対数尤度の最大化を+-- Nelder-Mead で行う (純粋関数のため runIdentity 経由)。+--+-- API:+--+-- > fitAFT :: AFTDistribution -> Matrix Double -> Vector Double+-- > -> Vector Bool -> IO (Either Text AFTFit)+-- > predictAFT :: AFTFit -> Matrix Double -> Vector Double -- 期待寿命+--+-- [English]: Accelerated Failure Time (AFT) parametric survival model.+--+-- AFT expresses the log of lifetime T as a linear function of covariates:+--+-- @+-- log T_i = X_i β + σ ε_i+-- @+--+-- The distribution of ε determines the family:+--+-- - 'AFTWeibull' : ε ~ Gumbel (the Weibull AFT of survival analysis).+-- - 'AFTLogNormal' : ε ~ Normal(0, 1).+-- - 'AFTLogLogistic': ε ~ Logistic(0, 1).+-- - 'AFTExponential': Weibull with σ = 1 fixed.+--+-- Supports right censoring. Estimation maximizes the log-likelihood via+-- Nelder-Mead (routed through runIdentity since it is a pure function).+--+-- API:+--+-- > fitAFT :: AFTDistribution -> Matrix Double -> Vector Double+-- > -> Vector Bool -> IO (Either Text AFTFit)+-- > predictAFT :: AFTFit -> Matrix Double -> Vector Double -- expected lifetime+module Hanalyze.Model.AFT+ ( AFTDistribution (..)+ , AFTFit (..)+ , fitAFT+ , predictAFT+ , logS+ -- ^ [日本語]: 標準化誤差 z の log 生存関数 (= 生存曲線描画に使用)。+ -- [English]: The log survival function of the standardized error z+ -- (used for drawing survival curves).+ ) where++import qualified Data.Vector as V+import qualified Numeric.LinearAlgebra as LA+import Data.Text (Text)+import qualified Data.Text as T+import qualified Statistics.Distribution as SD+import qualified Statistics.Distribution.Normal as ND++import Hanalyze.Optim.NelderMead (runNelderMeadWith, defaultNMConfig,+ NMConfig (..))+import Hanalyze.Optim.Common (OptimResult (..), StopCriteria (..))++-- ===========================================================================+-- 型+-- ===========================================================================++data AFTDistribution+ = AFTWeibull+ | AFTLogNormal+ | AFTLogLogistic+ | AFTExponential+ deriving (Show, Eq)++data AFTFit = AFTFit+ { aftBeta :: !(LA.Vector Double)+ , aftScale :: !Double -- ^ [日本語]: scale parameter σ。 [English]: The scale parameter σ.+ , aftLogLik :: !Double+ , aftDistribution :: !AFTDistribution+ , aftIters :: !Int+ } deriving (Show)++-- ===========================================================================+-- fit+-- ===========================================================================++-- | [日本語]: AFT モデルを MLE で fit する。+-- X: n × p 共変量、 t: n 観測時間 (> 0)、 delta: n failure indicator+-- (True = 観測、 False = 右側打ち切り)。+-- [English]: Fits an AFT model with MLE.+-- X: n × p covariates, t: n observed times (> 0), delta: n failure+-- indicators (True = observed, False = right-censored).+fitAFT+ :: AFTDistribution+ -> LA.Matrix Double+ -> LA.Vector Double+ -> V.Vector Bool+ -> IO (Either Text AFTFit)+fitAFT dist x t delta+ | LA.rows x /= LA.size t || LA.rows x /= V.length delta =+ pure (Left "fitAFT: input dimensions mismatch")+ | LA.size t == 0 =+ pure (Left "fitAFT: empty input")+ | V.any (<= 0) (V.fromList (LA.toList t)) =+ pure (Left "fitAFT: t must be > 0")+ | otherwise = do+ let p = LA.cols x+ -- intercept-only start: β_0 = mean(log t), β_j = 0 (j ≥ 1)+ logT = LA.cmap log t+ beta0 =+ let mu = LA.sumElements logT / fromIntegral (LA.size logT)+ in if p == 0+ then []+ else mu : replicate (p - 1) 0+ -- log σ を最後に追加 (Exponential では 0 固定)+ x0 = case dist of+ AFTExponential -> beta0+ _ -> beta0 ++ [0] -- log σ = 0 → σ = 1 として開始+ obj params =+ let (betaPart, logSigma) = case dist of+ AFTExponential -> (params, 0)+ _ -> (init params, last params)+ sigma = exp logSigma+ betaV = LA.fromList betaPart+ in negate (logLikAFT dist x t delta betaV sigma)+ cfg = defaultNMConfig+ { nmStop = StopCriteria+ { stMaxIter = 2000+ , stTolFun = 1e-8+ , stTolX = 1e-8+ }+ }+ res <- runNelderMeadWith cfg obj x0+ let xs = orBest res+ (betaPart, sigma) = case dist of+ AFTExponential -> (xs, 1)+ _ -> (init xs, exp (last xs))+ betaV = LA.fromList betaPart+ ll = logLikAFT dist x t delta betaV sigma+ pure (Right AFTFit+ { aftBeta = betaV+ , aftScale = sigma+ , aftLogLik = ll+ , aftDistribution = dist+ , aftIters = orIters res+ })++-- | [日本語]: 期待寿命の予測 E[T | X] = exp(X β + σ² / 2) -- log-normal の場合+-- Weibull AFT: E[T] = exp(X β) · Γ(1 + σ)+-- LogLogistic: E[T] = exp(X β) · π σ / sin(π σ) (σ < 1)+-- Exponential: E[T] = exp(X β)+-- [English]: Predicts expected lifetime E[T | X] = exp(X β + σ² / 2) --+-- for log-normal.+-- Weibull AFT: E[T] = exp(X β) · Γ(1 + σ).+-- LogLogistic: E[T] = exp(X β) · π σ / sin(π σ) (σ < 1).+-- Exponential: E[T] = exp(X β).+predictAFT :: AFTFit -> LA.Matrix Double -> LA.Vector Double+predictAFT fit xNew =+ let linPred = xNew LA.#> aftBeta fit+ sigma = aftScale fit+ adjust = case aftDistribution fit of+ AFTWeibull -> gammaApprox (1 + sigma)+ AFTLogNormal -> exp (sigma * sigma / 2)+ AFTLogLogistic ->+ if sigma < 1 && sigma > 0+ then pi * sigma / sin (pi * sigma)+ else 1 / 0 -- 平均が発散+ AFTExponential -> 1+ in LA.cmap (\lp -> exp lp * adjust) linPred++-- ===========================================================================+-- 内部 helpers+-- ===========================================================================++-- | [日本語]: 対数尤度。 censored は log S(t)、 observed は log f(t)。+-- [English]: The log-likelihood. Censored uses log S(t); observed uses+-- log f(t).+logLikAFT+ :: AFTDistribution+ -> LA.Matrix Double -> LA.Vector Double -> V.Vector Bool+ -> LA.Vector Double -> Double+ -> Double+logLikAFT dist x t delta beta sigma+ | sigma <= 0 = -1e15+ | otherwise =+ let n = LA.rows x+ eta = x LA.#> beta -- length n+ logT = LA.cmap log t -- length n+ zs = LA.cmap (/ sigma) (logT - eta)+ in sum+ [ let z = LA.atIndex zs i+ lt = LA.atIndex logT i+ obs = delta V.! i+ in if obs+ then logPDF dist sigma lt z+ else logS dist z+ | i <- [0 .. n - 1] ]++-- | [日本語]: log f(t) = log f_ε(z) − log σ − log t+-- [English]: log f(t) = log f_ε(z) − log σ − log t.+logPDF :: AFTDistribution -> Double -> Double -> Double -> Double+logPDF dist sigma logT z =+ let body = case dist of+ AFTWeibull -> z - exp z+ AFTExponential -> z - exp z+ AFTLogNormal -> -0.5 * z * z - 0.5 * log (2 * pi)+ AFTLogLogistic -> z - 2 * log1p (exp z)+ in body - log (max 1e-300 sigma) - logT++-- | [日本語]: log S(t) = log S_ε(z)+-- [English]: log S(t) = log S_ε(z).+logS :: AFTDistribution -> Double -> Double+logS dist z = case dist of+ AFTWeibull -> -exp z+ AFTExponential -> -exp z+ AFTLogNormal -> log (max 1e-300 (1 - SD.cumulative ND.standard z))+ AFTLogLogistic -> -log1p (exp z)++log1p :: Double -> Double+log1p x+ | abs x < 1e-4 = x - x * x / 2 + x * x * x / 3+ | otherwise = log (1 + x)++-- | [日本語]: Stirling 近似による Γ(x) (x > 0)。 AFT の平均補正で使うだけなので簡易版。+-- [English]: Γ(x) (x > 0) via Stirling's approximation. A simplified+-- version, since it is only used for the AFT mean correction.+gammaApprox :: Double -> Double+gammaApprox x+ | x <= 0 = 1 / 0+ | x < 1 = gammaApprox (x + 1) / x+ | otherwise =+ let n = floor (x - 1) :: Int+ frac = x - fromIntegral n - 1+ base = gammaStirling (1 + frac)+ in base * fromIntegral (product [1 .. n])+ where+ gammaStirling y =+ sqrt (2 * pi / y) * (y / exp 1) ** y+ * (1 + 1/(12*y) + 1/(288*y*y))
+ src/Hanalyze/Model/Cluster.hs view
@@ -0,0 +1,427 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE BangPatterns #-}+-- |+-- Module : Hanalyze.Model.Cluster+-- Description : クラスタリングアルゴリズム (k-means / silhouette / inertia)+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- Clustering algorithms.+--+-- Implements:+--+-- - 'kMeans' (Lloyd / Forgy / k-means++ initialisation, multi-restart)+-- - 'silhouette' (cluster quality metric)+-- - 'inertia' (within-cluster sum of squared distances)+--+-- Hierarchical and DBSCAN are deferred to a follow-up phase.+module Hanalyze.Model.Cluster+ ( -- * K-means+ KMeansConfig (..)+ , KMeansInit (..)+ , KMeansResult (..)+ , defaultKMeans+ , kMeans+ , kMeansPure+ -- * Quality metrics+ , silhouette+ , inertia+ -- * Helpers (exposed for advanced use)+ , assignLabels+ , updateCentroids+ ) where++import qualified Numeric.LinearAlgebra as LA+import qualified Hanalyze.Stat.KernelDist as KD+import qualified System.Random.MWC as MWC+import Control.Monad (forM_, foldM)+import Control.Monad.Primitive (PrimMonad, PrimState)+import Control.Monad.ST (ST, runST)+import qualified Data.Vector as V+import qualified Data.Vector.Mutable as VM+import qualified Data.Vector.Unboxed as VU+import qualified Data.Vector.Unboxed.Mutable as MVU+import qualified Data.Vector.Storable as VS+import qualified Data.Vector.Storable.Mutable as VSM+import Data.List (minimumBy)+import Data.Ord (comparing)+import Data.Word (Word32)++-- ---------------------------------------------------------------------------+-- K-means+-- ---------------------------------------------------------------------------++-- | Initialisation strategy.+data KMeansInit+ = Forgy -- ^ Pick k random data points.+ | KMeansPlus -- ^ k-means++ (Arthur & Vassilvitskii 2007).+ deriving (Show, Eq)++-- | K-means configuration.+data KMeansConfig = KMeansConfig+ { kmK :: !Int+ , kmInit :: !KMeansInit+ , kmMaxIter :: !Int+ , kmTol :: !Double+ , kmRestarts :: !Int+ } deriving (Show, Eq)++-- | Default: k-means++, 300 iters, tol 1e-4, 10 restarts.+defaultKMeans :: Int -> KMeansConfig+defaultKMeans k = KMeansConfig+ { kmK = k+ , kmInit = KMeansPlus+ , kmMaxIter = 300+ , kmTol = 1e-4+ , kmRestarts = 10+ }++-- | K-means result.+data KMeansResult = KMeansResult+ { kmrCentroids :: !(LA.Matrix Double)+ , kmrLabels :: ![Int]+ , kmrInertia :: !Double+ , kmrIters :: !Int+ , kmrConverged :: !Bool+ } deriving (Show)++-- | [日本語]: 'kmRestarts' 回の独立再実行を行い、 最も慣性の低い解を保持して+-- K-means をフィットする。+-- IO ラッパ。 ロジックは 'PrimMonad' 汎用の 'kMeansM' (mwc は 'PrimMonad'+-- 汎用ゆえ ST/IO で同コードを共有) をそのまま IO に特殊化したもの。+-- [English]: Fit K-means; runs 'kmRestarts' independent restarts and+-- keeps the lowest-inertia solution.+-- IO wrapper. The logic is 'kMeansM', generic over 'PrimMonad' (mwc is+-- generic over 'PrimMonad', so the same code is shared between ST\/IO),+-- specialized directly to IO.+kMeans :: KMeansConfig -> LA.Matrix Double -> MWC.GenIO -> IO KMeansResult+kMeans = kMeansM++-- | [日本語]: 純粋・決定的な K-means。 同じ @seed@ なら必ず同じ 'KMeansResult' を+-- 返す (同 seed → ビット同一・IO 不要)。 'kMeansM' を @ST@ で走らせ 'runST' で+-- 閉じる ([[phase-50-mcmc-purification-status]] の @nutsPure@ と同方針)。+-- [English]: Pure, deterministic K-means. The same @seed@ always returns+-- the same 'KMeansResult' (same seed → bit-identical, no IO required).+-- Runs 'kMeansM' in @ST@ and closes it with 'runST' (the same approach as+-- @nutsPure@ in [[phase-50-mcmc-purification-status]]).+kMeansPure :: KMeansConfig -> LA.Matrix Double -> Word32 -> KMeansResult+kMeansPure cfg x seed =+ runST (MWC.initialize (V.singleton seed) >>= kMeansM cfg x)++-- | [日本語]: 'PrimMonad' 汎用の K-means 本体。 'kMeans' (IO) / @kMeansPure@ (ST) が共有。+-- [English]: The K-means core, generic over 'PrimMonad', shared by+-- 'kMeans' (IO) and @kMeansPure@ (ST).+kMeansM :: PrimMonad m+ => KMeansConfig -> LA.Matrix Double -> MWC.Gen (PrimState m)+ -> m KMeansResult+kMeansM cfg x gen = do+ results <- mapM (\_ -> kMeansSingleRunM cfg x gen) [1 .. kmRestarts cfg]+ pure (minimumBy (comparing kmrInertia) results)++kMeansSingleRunM :: PrimMonad m+ => KMeansConfig -> LA.Matrix Double -> MWC.Gen (PrimState m)+ -> m KMeansResult+kMeansSingleRunM cfg x gen = do+ initC <- case kmInit cfg of+ Forgy -> forgyInitM (kmK cfg) x gen+ KMeansPlus -> kmppInitM (kmK cfg) x gen+ -- Hot loop: keep labels as 'VU.Vector Int' to avoid the per-iteration+ -- list↔Vector roundtrip the previous version paid via 'assignLabels'+ -- + 'updateCentroids' on @[Int]@.+ let loop !iter !centroids+ | iter >= kmMaxIter cfg = pure (centroids, iter, False)+ | otherwise = do+ let labelsV = assignLabelsV x centroids+ newC = updateCentroidsV x labelsV (kmK cfg)+ shift = LA.norm_2 (LA.flatten (newC - centroids))+ if shift < kmTol cfg+ then pure (newC, iter + 1, True)+ else loop (iter + 1) newC+ (finalC, iters, conv) <- loop 0 initC+ let labelsV = assignLabelsV x finalC+ pure KMeansResult+ { kmrCentroids = finalC+ , kmrLabels = VU.toList labelsV+ , kmrInertia = inertiaV x finalC labelsV+ , kmrIters = iters+ , kmrConverged = conv+ }++-- | Forgy initialisation: pick k random rows.+forgyInitM :: PrimMonad m+ => Int -> LA.Matrix Double -> MWC.Gen (PrimState m)+ -> m (LA.Matrix Double)+forgyInitM k x gen = do+ let n = LA.rows x+ xRowsV = V.fromList (LA.toRows x) -- O(1) row access+ idxs <- pickKDistinctM k n gen+ pure (LA.fromRows [xRowsV V.! i | i <- idxs])++-- | k-means++ initialisation: 1st centroid uniform random, subsequent+-- centroids weighted by squared distance to nearest existing centroid.+--+-- /Implementation/. Maintain @bestDist[i] = min_c ‖x_i − c‖²@ across+-- the centroids picked so far. Adding a new centroid is+-- __one BLAS GEMV__ + element-wise min, not a per-row Vector subtract / dot.+--+-- The previous version paid @n@ separate @LA.Vector@ allocations and+-- @n@ BLAS @ddot@ dispatches per centroid update (e.g. for+-- @n = 2000, k = 5@ that was ~10 000 length-@p@ allocations and+-- ~10 000 BLAS calls per kMeans run, ×10 restarts ≈ 100 000 allocs).+-- The fused-BLAS form below uses pre-computed row sq-norms and a+-- single matrix-vector multiply per centroid — O(np) work for the+-- whole sweep instead of O(n) per row.+kmppInitM :: PrimMonad m+ => Int -> LA.Matrix Double -> MWC.Gen (PrimState m)+ -> m (LA.Matrix Double)+kmppInitM k x gen = do+ let n = LA.rows x+ -- Pre-compute row squared norms once: ‖x_i‖² for all rows+ -- (length-n vector via @(X ⊙ X) · 1@).+ normsX = KD.rowSqNorms x++ -- Pick the first centroid.+ i0 <- MWC.uniformR (0, n - 1) gen+ -- bestDist[i] = ‖x_i − x_{i0}‖² in BLAS form:+ -- = ‖x_i‖² + ‖x_{i0}‖² − 2 x_iᵀ x_{i0}+ -- via @cross = X · x_{i0}@ (one GEMV), reusing 'normsX'.+ let initBest = sqDistsToRow x normsX i0++ pickWeighted total bdv =+ if total <= 0+ then pure 0+ else do+ u <- MWC.uniformR (0, total) gen+ -- Linear scan of the cumulative weights via VS.unsafeIndex.+ let go !acc !i+ | i >= n - 1 = pure i+ | otherwise = do+ let !nxt = acc + bdv `VS.unsafeIndex` i+ if u <= nxt+ then pure i+ else go nxt (i + 1)+ go 0 0++ -- IORef を foldM で純粋に畳む (純粋化のため・乱数列順は不変ゆえ+ -- 旧 IORef 版とビット同一)。 state = (bestDist, 逆順 centroid idx)。+ step (bd, acc) _ = do+ let !total = VS.sum bd+ pickIdx <- pickWeighted total bd+ -- One GEMV → length-n @sq dist to new centroid@; element-wise+ -- min with @bestDist@ in a single Storable Vector pass.+ let !newDist = sqDistsToRow x normsX pickIdx+ !updated = VS.zipWith min bd newDist+ pure (updated, pickIdx : acc)++ (_, idxsRev) <- foldM step (initBest, [i0]) [2 .. k]+ -- Build the @k × p@ centroid matrix from row indices in one shot.+ let xRowsV = V.fromList (LA.toRows x)+ pure (LA.fromRows [xRowsV V.! i | i <- reverse idxsRev])++-- | Squared distance from every row of @X@ (n × p) to @X[i, :]@,+-- via the BLAS identity+-- @‖x_a − x_i‖² = ‖x_a‖² + ‖x_i‖² − 2 x_aᵀ x_i@.+--+-- Cost: 1 GEMV (@O(np)@) plus one length-@n@ element-wise pass.+-- Used by @kmppInit@ to avoid per-row Vector subtract/dot.+sqDistsToRow+ :: LA.Matrix Double -- ^ Data matrix @X@ (@n × p@).+ -> LA.Vector Double -- ^ Pre-computed row squared norms.+ -> Int -- ^ Reference row index @i@.+ -> LA.Vector Double -- ^ Length-@n@ squared distances.+sqDistsToRow xMat normsX i =+ let xi = LA.flatten (xMat LA.?? (LA.Pos (LA.idxs [i]), LA.All))+ ni = normsX `LA.atIndex` i+ cross = xMat LA.#> xi -- length n, GEMV+ d = normsX + LA.scalar ni - LA.scale 2 cross+ in LA.cmap (max 0) d -- numerical-noise floor at 0++-- | Pick k distinct indices in [0, n) via Fisher-Yates partial.+pickKDistinctM :: PrimMonad m+ => Int -> Int -> MWC.Gen (PrimState m) -> m [Int]+pickKDistinctM k n gen = do+ v <- V.thaw (V.fromList [0 .. n - 1])+ forM_ [0 .. min k n - 1] $ \i -> do+ j <- MWC.uniformR (i, n - 1) gen+ a <- VM.read v i+ b <- VM.read v j+ VM.write v i b+ VM.write v j a+ V.toList . V.take k <$> V.freeze v++-- | Assign each row to its nearest centroid (Euclidean) — public API.+assignLabels :: LA.Matrix Double -> LA.Matrix Double -> [Int]+assignLabels x cs = VU.toList (assignLabelsV x cs)++-- | Vector version of 'assignLabels'. Internal hot path; the public+-- @assignLabels@ wraps with @VU.toList@ at the boundary.+--+-- /Implementation/. The full @n × k@ squared-distance matrix is+-- /not/ materialised. Instead we use the BLAS identity+--+-- @‖x_i − c_j‖² = ‖x_i‖² + ‖c_j‖² − 2 x_iᵀ c_j@+--+-- of which only the cross term @cross = X · Cᵀ@ depends on @j@+-- per-row, so the row-wise argmin is equivalent to+--+-- @argmin_j (‖c_j‖² − 2 cross[i, j])@+--+-- (the @‖x_i‖²@ term is constant across @j@). Replaces the previous+-- @KD.pairwiseSqDistXY x cs@ + scan pipeline, which built a full+-- @n × k@ Storable matrix only to read every cell once. Now: one+-- BLAS GEMM (@O(npk)@) plus a length-@nk@ argmin scan with a small+-- per-row constant — half the writes, lower cache pressure.+assignLabelsV :: LA.Matrix Double -> LA.Matrix Double -> VU.Vector Int+assignLabelsV x cs =+ let n = LA.rows x+ k = LA.rows cs+ normsC = KD.rowSqNorms cs -- length k+ cross = x LA.<> LA.tr cs -- n × k, single GEMM+ flatXC = LA.flatten cross+ in runST $ do+ lab <- MVU.new n+ let scanRow !i+ | i >= n = pure ()+ | otherwise = do+ let !base = i * k+ -- argmin_j of (‖c_j‖² − 2 X·Cᵀ[i, j]).+ pickArg !j !bestJ !bestVal+ | j >= k = bestJ+ | otherwise =+ let !v = (normsC `VS.unsafeIndex` j)+ - 2 * (flatXC `VS.unsafeIndex` (base + j))+ in if v < bestVal+ then pickArg (j + 1) j v+ else pickArg (j + 1) bestJ bestVal+ !v0 = (normsC `VS.unsafeIndex` 0)+ - 2 * (flatXC `VS.unsafeIndex` base)+ !bestJ0 = pickArg 1 0 v0+ MVU.unsafeWrite lab i bestJ0+ scanRow (i + 1)+ scanRow 0+ VU.unsafeFreeze lab++-- | Recompute centroids — public API. Wraps @updateCentroidsV@.+updateCentroids :: LA.Matrix Double -> [Int] -> Int -> LA.Matrix Double+updateCentroids x labels k = updateCentroidsV x (VU.fromList labels) k++-- | Vector version of 'updateCentroids'. Internal hot path.+--+-- Single-pass scatter-add: traverse the @n × p@ data matrix once,+-- accumulating each row into its assigned cluster's running sum and+-- bumping that cluster's count. Centroids are then @sum / count@.+-- Replaces the previous @[ [r | (r,l) ← zip rows labels, l == c]+-- | c ← [0..k-1] ]@ which scanned the whole label list once /per/+-- cluster — @O(n k)@ per call vs the new @O(n p)@.+updateCentroidsV+ :: LA.Matrix Double -> VU.Vector Int -> Int -> LA.Matrix Double+updateCentroidsV x labels k =+ let n = LA.rows x+ p = LA.cols x+ flat = LA.flatten x -- length n*p, row-major+ out = runST $ do+ -- VSM.replicate avoids the explicit init forM_ loops.+ sumBuf <- VSM.replicate (k * p) (0 :: Double)+ cntBuf <- MVU.replicate k (0 :: Int)+ :: ST s (MVU.STVector s Int)+ -- Single pass over all rows. Tail-recursive Int loops keep the+ -- whole pass list-free; the previous @forM_ [0..n-1]@ ++ -- @forM_ [0..p-1]@ relied on GHC's list-fusion rewrite, which+ -- adds Haskell-level monadic-bind overhead for very small+ -- inner @p@.+ let goRow !i+ | i >= n = pure ()+ | otherwise = do+ let !l = labels `VU.unsafeIndex` i+ !off = i * p+ !sof = l * p+ goCol !j+ | j >= p = pure ()+ | otherwise = do+ old <- VSM.unsafeRead sumBuf (sof + j)+ VSM.unsafeWrite sumBuf (sof + j)+ (old + flat `VS.unsafeIndex` (off + j))+ goCol (j + 1)+ goCol 0+ c0 <- MVU.unsafeRead cntBuf l+ MVU.unsafeWrite cntBuf l (c0 + 1)+ goRow (i + 1)+ goRow 0+ -- Divide each cluster's sum by its count.+ let goNorm !c+ | c >= k = pure ()+ | otherwise = do+ cnt <- MVU.unsafeRead cntBuf c+ let !invN = if cnt == 0 then 0+ else 1 / fromIntegral cnt+ !sof = c * p+ goScale !j+ | j >= p = pure ()+ | otherwise = do+ v <- VSM.unsafeRead sumBuf (sof + j)+ VSM.unsafeWrite sumBuf (sof + j) (v * invN)+ goScale (j + 1)+ goScale 0+ goNorm (c + 1)+ goNorm 0+ VS.unsafeFreeze sumBuf+ in LA.reshape p out++-- | Sum of squared Euclidean distances — public API.+inertia :: LA.Matrix Double -> LA.Matrix Double -> [Int] -> Double+inertia x cs labels = inertiaV x cs (VU.fromList labels)++-- | Vector version. Single pass over the @n × p@ data matrix and the+-- @k × p@ centroid matrix, accumulating @‖x_i − c_{l_i}‖²@ via flat+-- indexing — no @LA.toRows@ list, no @cRows !! l@ list-index per row.+inertiaV+ :: LA.Matrix Double -> LA.Matrix Double -> VU.Vector Int -> Double+inertiaV x cs labels =+ let n = LA.rows x+ p = LA.cols x+ flatX = LA.flatten x+ flatC = LA.flatten cs+ go !i !acc+ | i >= n = acc+ | otherwise =+ let l = labels VU.! i+ !off = i * p+ !cof = l * p+ rowSq !j !s+ | j >= p = s+ | otherwise =+ let !d = (flatX `VS.unsafeIndex` (off + j))+ - (flatC `VS.unsafeIndex` (cof + j))+ in rowSq (j + 1) (s + d * d)+ in go (i + 1) (acc + rowSq 0 0)+ in go 0 0++-- ---------------------------------------------------------------------------+-- Quality+-- ---------------------------------------------------------------------------++-- | Silhouette coefficient. Mean over samples of+-- @(b − a) / max(a, b)@ where @a@ is the mean distance to other points+-- in the same cluster and @b@ is the mean distance to the closest+-- other cluster. Range @[-1, 1]@; higher is better.+silhouette :: LA.Matrix Double -> [Int] -> Double+silhouette x labels =+ let n = LA.rows x+ d2 = KD.pairwiseSqDist x+ d = LA.cmap sqrt d2+ lvec = V.fromList labels+ uniqL = V.toList (V.fromList (foldr (\l acc ->+ if l `elem` acc then acc else l:acc) [] labels))+ meanD i js+ | null js = 0+ | otherwise = sum [LA.atIndex d (i, j) | j <- js]+ / fromIntegral (length js)+ sIof i =+ let li = lvec V.! i+ ai = meanD i [j | j <- [0..n-1], j /= i, lvec V.! j == li]+ otherClusters = filter (/= li) uniqL+ bi = if null otherClusters then 0+ else minimum [meanD i [j | j <- [0..n-1], lvec V.! j == c]+ | c <- otherClusters]+ in if max ai bi == 0 then 0 else (bi - ai) / max ai bi+ in if n == 0 then 0 else sum [sIof i | i <- [0..n-1]] / fromIntegral n
+ src/Hanalyze/Model/CompetingRisks.hs view
@@ -0,0 +1,114 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE BangPatterns #-}+-- |+-- Module : Hanalyze.Model.CompetingRisks+-- Description : 競合リスク生存解析 (累積発生関数 CIF 推定)+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- Competing-risks survival analysis.+--+-- Extends 'Hanalyze.Model.Survival' to settings with multiple, mutually+-- exclusive failure causes. Implements the non-parametric Cumulative+-- Incidence Function (CIF) estimator (Kalbfleisch & Prentice 1980):+--+-- @+-- F̂_k(t) = Σ_{t_i ≤ t} Ŝ(t_i⁻) · (d_{k,i} / n_i)+-- @+--+-- where @Ŝ@ is the overall Kaplan-Meier survival treating *any* cause as+-- an event, @d_{k,i}@ is the number of failures from cause @k@ at time+-- @t_i@, and @n_i@ is the size of the risk set just before @t_i@.+--+-- The naïve approach of taking @1 - KM@ on cause-specific data ignores+-- competing events and biases the cumulative incidence upward; this+-- estimator is the canonical correction.+--+-- @+-- import Hanalyze.Model.CompetingRisks+--+-- let samples = [ CRSample 1.2 1, CRSample 2.5 2, CRSample 3.0 0, … ]+-- fit = fitCompetingRisks samples+-- @+--+-- == Implemented+--+-- * 'fitCompetingRisks' (per-cause CIF on the distinct event grid)+module Hanalyze.Model.CompetingRisks+ ( CRSample (..)+ , CRFit (..)+ , fitCompetingRisks+ ) where++import qualified Numeric.LinearAlgebra as LA+import Data.List (sort, nub, sortBy)+import Data.Ord (comparing)++-- ---------------------------------------------------------------------------+-- Types+-- ---------------------------------------------------------------------------++-- | A single observation with cause-of-failure indicator.+-- @crCause = 0@ ↔ right-censored, @crCause ≥ 1@ ↔ failure from that cause.+data CRSample = CRSample+ { crTime :: !Double+ , crCause :: !Int+ } deriving (Show, Eq)++-- | Fitted competing-risks estimator: cumulative incidence per cause,+-- evaluated on the distinct event times (causes 1, …, K combined).+data CRFit = CRFit+ { crfCauses :: ![Int] -- ^ Cause labels (sorted).+ , crfTimes :: !(LA.Vector Double) -- ^ Distinct event times.+ , crfCIF :: ![(Int, LA.Vector Double)] -- ^ Per-cause CIF values+ -- on @crfTimes@.+ , crfOverallSurvival :: !(LA.Vector Double) -- ^ Overall KM survival+ -- on @crfTimes@.+ } deriving (Show)++-- ---------------------------------------------------------------------------+-- Fitting+-- ---------------------------------------------------------------------------++-- | Estimate the cumulative incidence function for each observed cause.+-- Inputs need not be sorted; ties at the same time are handled jointly.+fitCompetingRisks :: [CRSample] -> CRFit+fitCompetingRisks samples =+ let sorted = sortBy (comparing crTime) samples+ causes = sort (nub [ c | CRSample _ c <- sorted, c > 0 ])+ eventTimes = sort (nub [ crTime s | s <- sorted, crCause s > 0 ])+ -- Number at risk just before time t: #{s | crTime s >= t}.+ atRisk t = length [ s | s <- sorted, crTime s >= t ]+ atTime t = [ s | s <- sorted, crTime s == t ]+ -- Per-event-time row: (S(t⁻) before update, n at risk, total d, per-cause d)+ step !sPrev t =+ let here = atTime t+ events = [ c | CRSample _ c <- here, c > 0 ]+ dTot = length events+ n = atRisk t+ sNew = sPrev * (1 - fromIntegral dTot / fromIntegral n)+ incs = [ ( k+ , sPrev * fromIntegral (length [ c | c <- events, c == k ])+ / fromIntegral n )+ | k <- causes ]+ in (sNew, incs)+ walk _ [] = ([], [])+ walk !sPrev (t : ts) =+ let (sNew, incs) = step sPrev t+ (ss, incss) = walk sNew ts+ in (sNew : ss, incs : incss)+ (survList, incList) = walk 1.0 eventTimes+ sVec = LA.fromList survList+ -- Cumulate increments per cause across the event-time grid.+ cumulate inc = scanl1 (+) inc+ cifByCause k =+ let perTimeInc = [ snd (head [ (k', v) | (k', v) <- row, k' == k ])+ | row <- incList ]+ in LA.fromList (cumulate perTimeInc)+ cifs = [ (k, cifByCause k) | k <- causes ]+ in CRFit+ { crfCauses = causes+ , crfTimes = LA.fromList eventTimes+ , crfCIF = cifs+ , crfOverallSurvival = sVec+ }
+ src/Hanalyze/Model/DAG.hs view
@@ -0,0 +1,295 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE BangPatterns #-}+-- |+-- Module : Hanalyze.Model.DAG+-- Description : DAG (有向非巡回グラフ) の共通表現 (重み付き隣接行列)+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- [日本語]: Directed Acyclic Graph (DAG) の共通表現。+--+-- 因果探索 (LiNGAM 系) / 将来の SEM / Bayesian Network の出力型を統一する。+-- 内部表現は __重み付き隣接行列__ で、 hmatrix の線形代数操作との親和性を保つ。+--+-- ## 規約+--+-- 重み行列 W (p × p) の要素 W[i, j] は __エッジ j → i の重み__ を表す。+-- これは構造方程式 X_i = Σ_j W[i, j] · X_j + e_i に対応する自然な向きで、+-- LiNGAM の B 行列と完全一致する。 W[i, i] = 0 (self-loop 禁止)。+--+-- ## DAG 判定+--+-- 'isAcyclic' は W の非零パターンから到達可能性を見て循環を検出する。+-- 浮動小数閾値の影響を避けるため、 判定は 'dagW' の __絶対値 > 0__ マスク+-- に対して実施。 ノイズで小さな非零が出る場合は事前に 'pruneByThreshold'+-- でクリーンナップする。+--+-- [English]: A common representation for Directed Acyclic Graphs+-- (DAGs).+--+-- Unifies the output type for causal discovery (LiNGAM family) \/+-- future SEM \/ Bayesian Network. The internal representation is a+-- __weighted adjacency matrix__, which stays compatible with hmatrix's+-- linear-algebra operations.+--+-- ## Convention+--+-- Element W[i, j] of the weight matrix W (p × p) represents+-- __the weight of the edge j → i__. This is the natural direction+-- corresponding to the structural equation+-- X_i = Σ_j W[i, j] · X_j + e_i, and matches LiNGAM's B matrix exactly.+-- W[i, i] = 0 (self-loops are forbidden).+--+-- ## DAG check+--+-- 'isAcyclic' detects cycles by looking at reachability over W's+-- nonzero pattern. To avoid the influence of floating-point noise, the+-- check is performed against a mask of 'dagW''s __absolute value > 0__.+-- If noise produces small nonzero values, clean them up beforehand with+-- 'pruneByThreshold'.+module Hanalyze.Model.DAG+ ( DAG (..)+ , Edge (..)+ -- 構築+ , mkDAG+ , fromAdjacency+ , fromBMatrix+ , withNames+ -- 操作+ , pruneByThreshold+ -- 問合せ+ , dagEdges+ , dagParents+ , dagChildren+ , dagNodeName+ , topoSort+ , isAcyclic+ , dagReachable+ -- 出力+ , toDOT+ ) where++import qualified Data.Set as S+import qualified Data.Text as T+import qualified Data.Vector as V+import qualified Numeric.LinearAlgebra as LA+import Data.Text (Text)+import Data.List (foldl')++-- ===========================================================================+-- 型+-- ===========================================================================++data DAG = DAG+ { dagN :: !Int+ -- ^ [日本語]: ノード数。 [English]: The number of nodes.+ , dagNames :: !(Maybe (V.Vector Text))+ -- ^ [日本語]: ノード名 (任意)。 'Nothing' なら "x0".."x(n-1)" を使う。+ -- [English]: Node names (optional). If 'Nothing', uses+ -- "x0".."x(n-1)".+ , dagW :: !(LA.Matrix Double)+ -- ^ [日本語]: 重み付き隣接行列 (p × p)。 W[i, j] = エッジ j → i の重み。+ -- [English]: The weighted adjacency matrix (p × p).+ -- W[i, j] = the weight of the edge j → i.+ } deriving (Show)++data Edge = Edge+ { edgeFrom :: !Int+ , edgeTo :: !Int+ , edgeWeight :: !Double+ } deriving (Show, Eq)++-- ===========================================================================+-- 構築+-- ===========================================================================++-- | [日本語]: 重み付き隣接行列から DAG を作る。 ノード数は W の行数。 W が+-- p × p でない場合は呼出側のバグ (here で error)。+-- [English]: Builds a DAG from a weighted adjacency matrix. The node+-- count is W's row count. If W is not p × p, that is a caller bug+-- (raises an error here).+mkDAG :: LA.Matrix Double -> DAG+mkDAG w+ | LA.rows w /= LA.cols w =+ error "Hanalyze.Model.DAG.mkDAG: W は p × p 正方行列でなければならない"+ | otherwise = DAG+ { dagN = LA.rows w+ , dagNames = Nothing+ , dagW = w+ }++-- | [日本語]: 0/1 隣接行列から DAG。 重みはエッジ存在を 1 として保持。+-- [English]: Builds a DAG from a 0/1 adjacency matrix. The weight+-- holds 1 for edge presence.+fromAdjacency :: LA.Matrix Double -> DAG+fromAdjacency = mkDAG++-- | [日本語]: LiNGAM B 行列 + threshold から DAG を構築。 |B[i, j]| ≤ thr の+-- エッジは刈り取る。 対角要素は常に 0。+-- [English]: Builds a DAG from a LiNGAM B matrix + threshold. Edges+-- with |B[i, j]| ≤ thr are pruned. Diagonal elements are always 0.+fromBMatrix :: Double -> LA.Matrix Double -> DAG+fromBMatrix thr b = mkDAG (pruned b)+ where+ pruned m =+ let p = LA.rows m+ f i j+ | i == j = 0+ | abs (LA.atIndex m (i, j)) <= thr = 0+ | otherwise = LA.atIndex m (i, j)+ in LA.build (p, p) (\i j -> f (round i) (round j) :: Double)++-- | [日本語]: ノード名を付与する (length 不一致は呼出側のバグ)。+-- [English]: Attaches node names (a length mismatch is a caller bug).+withNames :: V.Vector Text -> DAG -> DAG+withNames ns g+ | V.length ns /= dagN g =+ error "Hanalyze.Model.DAG.withNames: ノード数と名前数が不一致"+ | otherwise = g { dagNames = Just ns }++-- ===========================================================================+-- 操作+-- ===========================================================================++-- | [日本語]: |W[i, j]| ≤ thr のエッジを 0 に。 自己ループは常に 0。+-- [English]: Zeroes out edges with |W[i, j]| ≤ thr. Self-loops are+-- always 0.+pruneByThreshold :: Double -> DAG -> DAG+pruneByThreshold thr g = g { dagW = pruned }+ where+ p = dagN g+ f i j+ | i == j = 0+ | abs (LA.atIndex (dagW g) (i, j)) <= thr = 0+ | otherwise = LA.atIndex (dagW g) (i, j)+ pruned = LA.build (p, p) (\i j -> f (round i) (round j) :: Double)++-- ===========================================================================+-- 問合せ+-- ===========================================================================++-- | [日本語]: 全エッジを (from, to, weight) のリストで返す (非零重みのみ)。+-- [English]: Returns all edges as a list of (from, to, weight)+-- (nonzero weights only).+dagEdges :: DAG -> [Edge]+dagEdges g =+ let p = dagN g+ w = dagW g+ in [ Edge j i (LA.atIndex w (i, j))+ | i <- [0 .. p - 1]+ , j <- [0 .. p - 1]+ , i /= j+ , LA.atIndex w (i, j) /= 0+ ]++-- | [日本語]: ノード i に直接影響を与えるノード集合 (W[i, j] ≠ 0 となる j のリスト)。+-- [English]: The set of nodes that directly influence node i (the+-- list of j with W[i, j] ≠ 0).+dagParents :: DAG -> Int -> [Int]+dagParents g i =+ [ j | j <- [0 .. dagN g - 1]+ , j /= i+ , LA.atIndex (dagW g) (i, j) /= 0 ]++-- | [日本語]: ノード i から直接影響を受けるノード集合 (W[k, i] ≠ 0 となる k のリスト)。+-- [English]: The set of nodes directly influenced by node i (the+-- list of k with W[k, i] ≠ 0).+dagChildren :: DAG -> Int -> [Int]+dagChildren g i =+ [ k | k <- [0 .. dagN g - 1]+ , k /= i+ , LA.atIndex (dagW g) (k, i) /= 0 ]++-- | [日本語]: ノード名取得 ('dagNames' が Nothing なら "x{idx}")。+-- [English]: Gets a node name (if 'dagNames' is Nothing, "x{idx}").+dagNodeName :: DAG -> Int -> Text+dagNodeName g i = case dagNames g of+ Just ns | i >= 0 && i < V.length ns -> ns V.! i+ _ -> T.pack ("x" <> show i)++-- | [日本語]: 到達可能性: from から to へ DAG エッジを辿って到達可能か。+-- [English]: Reachability: whether to is reachable from from by+-- following DAG edges.+dagReachable :: DAG -> Int -> Int -> Bool+dagReachable g from to = go S.empty [from]+ where+ go _ [] = False+ go seen (x:xs)+ | x == to = True+ | x `S.member` seen = go seen xs+ | otherwise =+ let !seen' = S.insert x seen+ kids = dagChildren g x+ in go seen' (kids ++ xs)++-- | [日本語]: 循環を含まないか。 全ノード対 (i, j) について 「j から i へ到達可能か+-- つ i → j のエッジが存在する」 ならば循環。+-- [English]: Whether the graph contains no cycle. For every node+-- pair (i, j), if "i is reachable from j, and an edge i → j exists"+-- then it is a cycle.+isAcyclic :: DAG -> Bool+isAcyclic g =+ let !p = dagN g+ cyclePair i j =+ i /= j+ && LA.atIndex (dagW g) (j, i) /= 0+ && dagReachable g j i+ in not $ or [ cyclePair i j | i <- [0 .. p - 1], j <- [0 .. p - 1] ]++-- | [日本語]: topological sort: 根 (parents なし) から葉までの並び。+-- 循環を検出した場合は 'Nothing'。 Kahn のアルゴリズム (Pure 版)。+-- [English]: Topological sort: an ordering from the roots (no+-- parents) to the leaves. Returns 'Nothing' if a cycle is detected.+-- Kahn's algorithm (a pure version).+topoSort :: DAG -> Maybe [Int]+topoSort g =+ let !p = dagN g+ inDeg0 = V.fromList [ length (dagParents g i) | i <- [0 .. p - 1] ]+ go acc inDeg remaining+ | null remaining = Just (reverse acc)+ | otherwise =+ case findRoot remaining inDeg of+ Nothing -> Nothing -- 循環+ Just r ->+ let kids = dagChildren g r+ inDegN = V.imap+ (\idx v -> if idx `elem` kids then v - 1 else v)+ inDeg+ in go (r : acc) inDegN (filter (/= r) remaining)+ in go [] inDeg0 [0 .. p - 1]+ where+ findRoot xs inDeg =+ case filter (\i -> (inDeg V.! i) == 0) xs of+ [] -> Nothing+ (h:_) -> Just h++-- ===========================================================================+-- 出力+-- ===========================================================================++-- | [日本語]: Graphviz DOT 形式で出力。 シェル経由で+-- @echo "..." | dot -Tpng -o dag.png@ で可視化可能。+-- [English]: Outputs the graph in Graphviz DOT format. Can be+-- visualized via the shell with+-- @echo "..." | dot -Tpng -o dag.png@.+toDOT :: DAG -> Text+toDOT g =+ let header = T.pack "digraph G {\n rankdir=LR;\n"+ footer = T.pack "}\n"+ nodes = T.concat+ [ T.pack " " <> sanitize (dagNodeName g i)+ <> T.pack " [label=\"" <> dagNodeName g i <> T.pack "\"];\n"+ | i <- [0 .. dagN g - 1] ]+ edges = T.concat+ [ T.pack " " <> sanitize (dagNodeName g (edgeFrom e))+ <> T.pack " -> " <> sanitize (dagNodeName g (edgeTo e))+ <> T.pack " [label=\""+ <> T.pack (showWeight (edgeWeight e))+ <> T.pack "\"];\n"+ | e <- dagEdges g ]+ in header <> nodes <> edges <> footer+ where+ sanitize = T.replace (T.pack " ") (T.pack "_")+ . T.replace (T.pack "-") (T.pack "_")+ showWeight w = let r = round (w * 1000) :: Int+ in show (fromIntegral r / 1000 :: Double)
+ src/Hanalyze/Model/DecisionTree.hs view
@@ -0,0 +1,552 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE BangPatterns #-}+-- |+-- Module : Hanalyze.Model.DecisionTree+-- Description : 決定木分類器 (CART, classification)+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- Decision tree classifier (CART, classification).+--+-- Pairs with the existing regression-oriented 'Hanalyze.Model.RandomForest';+-- this module focuses on classification. Splits use Gini impurity as+-- the criterion (matches sklearn default).+--+-- @+-- import Hanalyze.Model.DecisionTree+--+-- let cfg = defaultDecisionTree+-- tree = fitDT cfg xs ys -- xs :: [[Double]], ys :: [Int]+-- yhat = map (predictDT tree) xs+-- @+--+-- /Performance/: the primary fit API is now 'fitDTV', which takes a+-- contiguous 'LA.Matrix' of features and an unboxed 'VU.Vector' of+-- labels. The classic 'fitDT' over @[[Double]]@ / @[Int]@ is preserved+-- as a backwards-compatible wrapper that converts at the boundary.+-- The internal representation keeps a single shared feature matrix+-- and recurses on row-index permutations, so building a tree is+-- @O(p · n log n · depth)@ rather than the old @O(p · n² · depth)@.+module Hanalyze.Model.DecisionTree+ ( -- * Tree types+ DTree (..)+ , DTFit (..)+ , DTConfig (..)+ , defaultDecisionTree+ -- * Fit / predict+ , fitDT+ , fitDTV+ , predictDT+ , predictDTProbs+ -- * Text export (R @print.rpart@ 相当)+ , printRpart+ , printRpartRaw+ -- * Helpers+ , giniImpurity+ ) where++import qualified Data.Map.Strict as Map+import qualified Data.Vector as V+import qualified Data.Vector.Unboxed as VU+import qualified Data.Vector.Unboxed.Mutable as VUM+import qualified Data.Vector.Algorithms.Intro as Intro+import qualified Numeric.LinearAlgebra as LA+import Control.Monad.ST (runST)+import Data.List (foldl')+import Data.Text (Text)+import qualified Data.Text as T+import Numeric (showFFloat)++-- ---------------------------------------------------------------------------+-- Types+-- ---------------------------------------------------------------------------++-- | [日本語]: 決定木。 各ノードに __サンプル数 n / gini 不純度 / クラス分布 / 多数決クラス__+-- を保持する (rpart.plot / sklearn plot_tree 水準の樹形図・ルールテキスト出力のため)。+-- 予測 (predict) の数値は不変。+-- [English]: A classification decision tree node. Each node holds the+-- __sample count n / Gini impurity / class distribution / majority class__+-- (for rpart.plot \/ sklearn plot_tree-level tree diagrams and rule-text+-- output). The predicted values are unaffected.+data DTree+ = DLeaf+ { dlClassProbs :: !(Map.Map Int Double) -- ^ [日本語]: クラス割合。 [English]: The class proportions.+ , dlMajority :: !Int -- ^ [日本語]: 多数決クラス (予測)。 [English]: The majority class (the prediction).+ , dlN :: !Int -- ^ [日本語]: このノードのサンプル数。 [English]: The sample count at this node.+ , dlImpurity :: !Double -- ^ [日本語]: gini 不純度。 [English]: The Gini impurity.+ }+ | DNode+ { dnFeature :: !Int+ , dnThr :: !Double+ , dnLeft :: !DTree+ , dnRight :: !DTree+ , dnN :: !Int -- ^ [日本語]: このノードのサンプル数。 [English]: The sample count at this node.+ , dnImpurity :: !Double -- ^ [日本語]: 分割前の gini 不純度。 [English]: The Gini impurity before the split.+ , dnProbs :: !(Map.Map Int Double) -- ^ [日本語]: 分割前のクラス割合。 [English]: The class proportions before the split.+ , dnMajority :: !Int -- ^ [日本語]: 分割前の多数決クラス。 [English]: The majority class before the split.+ }+ deriving (Show)++-- | [日本語]: 学習済み決定木 + 表示メタ (特徴量名・クラス名)。 高レベル @df |-> decisionTree@+-- ('Hanalyze.Fit') が fit 時に手元の実列名とクラス列の levels を載せて返す+-- ('RandomForestClassifier.RFClassifierFit' と同型のラッパ)。 これにより @treePlot@ /+-- @printRpart@ は名前を手渡しせず @DTFit@ 一つで済む。 クラス番号 (0..K-1) は+-- @dtClassNames !! k@ で名前が引ける。+-- [English]: A fitted decision tree + display metadata (feature names,+-- class names). The high-level @df |-> decisionTree@ ('Hanalyze.Fit')+-- returns the actual column names and the class column's levels at fit+-- time (a wrapper isomorphic to 'RandomForestClassifier.RFClassifierFit').+-- This lets @treePlot@ \/ @printRpart@ work with a single @DTFit@ instead+-- of passing names separately. The name for class index (0..K-1) can be+-- looked up via @dtClassNames !! k@.+data DTFit = DTFit+ { dtTree :: !DTree -- ^ [日本語]: 学習済み木。 [English]: The fitted tree.+ , dtFeatureNames :: ![Text] -- ^ [日本語]: 特徴量名 (fit に使った列順)。 [English]: The feature names (in the column order used for fitting).+ , dtClassNames :: ![Text] -- ^ [日本語]: クラス名 (label 0..K-1 に対応する levels)。 [English]: The class names (the levels corresponding to labels 0..K-1).+ } deriving (Show)++-- | Decision tree configuration.+data DTConfig = DTConfig+ { dtMaxDepth :: !(Maybe Int)+ , dtMinSamplesSplit :: !Int+ , dtMinSamplesLeaf :: !Int+ , dtMinImpurity :: !Double+ } deriving (Show, Eq)++-- | Defaults (sklearn-compatible): unlimited depth, min split 2,+-- min leaf 1, min impurity 0.+defaultDecisionTree :: DTConfig+defaultDecisionTree = DTConfig+ { dtMaxDepth = Nothing+ , dtMinSamplesSplit = 2+ , dtMinSamplesLeaf = 1+ , dtMinImpurity = 0+ }++-- ---------------------------------------------------------------------------+-- Fit (Vector-based primary API)+-- ---------------------------------------------------------------------------++-- | Fit a decision tree from a row-major feature matrix and unboxed+-- label vector. This is the high-performance path; 'fitDT' is a+-- list-based backwards-compatibility wrapper.+fitDTV :: DTConfig -> LA.Matrix Double -> VU.Vector Int -> DTree+fitDTV cfg x y =+ let !n = VU.length y+ !idx = VU.enumFromN 0 n+ in buildNodeV cfg x y idx 0++-- | Backwards-compatible list-based fit.+fitDT :: DTConfig -> [[Double]] -> [Int] -> DTree+fitDT cfg xs ys+ | null xs = DLeaf Map.empty 0 0 0+ | otherwise = fitDTV cfg (LA.fromLists xs) (VU.fromList ys)++-- ---------------------------------------------------------------------------+-- Recursive build over row-index permutations+-- ---------------------------------------------------------------------------++buildNodeV+ :: DTConfig+ -> LA.Matrix Double -- ^ Shared feature matrix (n × p).+ -> VU.Vector Int -- ^ Shared label vector (length n).+ -> VU.Vector Int -- ^ Row indices in this subtree.+ -> Int -- ^ Current depth.+ -> DTree+buildNodeV cfg x y idx depth =+ let !nIdx = VU.length idx+ !sublabs = VU.map (y VU.!) idx+ !probs = classProbsV sublabs+ !gini = giniFromCounts probs+ !majority = argMaxClass probs+ leaf = DLeaf probs majority nIdx gini++ depthLimit = case dtMaxDepth cfg of+ Just d -> depth >= d+ Nothing -> False+ stop = depthLimit+ || nIdx < dtMinSamplesSplit cfg+ || gini < dtMinImpurity cfg+ || allSameV sublabs+ in if stop+ then leaf+ else case bestSplitV cfg x y idx of+ Nothing -> leaf+ Just (fIdx, thr, _gain) ->+ let (lIdx, rIdx) = partitionVIdx x idx fIdx thr+ in if VU.length lIdx < dtMinSamplesLeaf cfg+ || VU.length rIdx < dtMinSamplesLeaf cfg+ then leaf+ else DNode+ { dnFeature = fIdx+ , dnThr = thr+ , dnLeft = buildNodeV cfg x y lIdx (depth + 1)+ , dnRight = buildNodeV cfg x y rIdx (depth + 1)+ , dnN = nIdx+ , dnImpurity = gini+ , dnProbs = probs+ , dnMajority = majority+ }++-- | Partition row indices by a feature threshold.+partitionVIdx+ :: LA.Matrix Double+ -> VU.Vector Int+ -> Int+ -> Double+ -> (VU.Vector Int, VU.Vector Int)+partitionVIdx x idx feat thr =+ let pred_ i = LA.atIndex x (i, feat) <= thr+ in VU.partition pred_ idx++-- ---------------------------------------------------------------------------+-- Class probabilities and Gini on subsets+-- ---------------------------------------------------------------------------++-- | Class probability map (class → fraction).+classProbsV :: VU.Vector Int -> Map.Map Int Double+classProbsV ys =+ let !n = fromIntegral (VU.length ys) :: Double+ counts = VU.foldl'+ (\m c -> Map.insertWith (+) c (1 :: Double) m)+ Map.empty ys+ in Map.map (/ n) counts++allSameV :: VU.Vector Int -> Bool+allSameV ys+ | VU.null ys = True+ | otherwise =+ let !y0 = VU.unsafeHead ys+ in VU.all (== y0) (VU.unsafeTail ys)++giniFromCounts :: Map.Map Int Double -> Double+giniFromCounts ps = 1 - foldl' (\acc p -> acc + p * p) 0 (Map.elems ps)++-- | Backwards-compatible Gini on @[Int]@.+giniImpurity :: [Int] -> Double+giniImpurity [] = 0+giniImpurity ys =+ let !n = fromIntegral (length ys) :: Double+ counts = foldl' (\m c -> Map.insertWith (+) c (1 :: Double) m)+ Map.empty ys+ in 1 - foldl' (\acc c -> acc + (c / n) ^ (2 :: Int)) 0 (Map.elems counts)++-- | [日本語]: 多数決 (予測) クラス = 確率最大のクラス。 同点は __最小クラス index__ を選ぶ+-- (rpart / sklearn 慣例)。 @Map.toList@ は昇順 key なので、 @foldl'@ で「厳密に+-- 大きい確率でだけ更新」すれば先勝ち = 最小 index の同点タイブレークになる。+--+-- ⚠ 旧 @sortByValDescV@ は名前に反して昇順を返し (@reverse . 降順ソート@)、+-- @head@ が __最小確率クラス (argmin)__ を拾っていた。 深さ無制限で葉が純粋な間は+-- 露見しないが、 depth/min_samples で止まった混在葉で予測が少数派に化ける実バグ+-- だった (樹形図を目視して発覚・修正)。+--+-- [English]: The majority (predicted) class = the class with the highest+-- probability. Ties are broken by choosing the __smallest class index__+-- (rpart \/ sklearn convention). Since @Map.toList@ has ascending keys,+-- @foldl'@ that "updates only on a strictly larger probability" gives a+-- first-wins tiebreak = the smallest index among ties wins.+--+-- ⚠ The old @sortByValDescV@, despite its name, returned an ascending+-- order (@reverse . descending sort@), so @head@ picked up the+-- __smallest-probability class (argmin)__. This went unnoticed while+-- depth was unlimited and leaves stayed pure, but was a real bug in which+-- predictions for mixed leaves stopped by depth\/min_samples flipped to+-- the minority class (discovered and fixed by visually inspecting a tree+-- diagram).+argMaxClass :: Map.Map Int Double -> Int+argMaxClass m = case Map.toList m of+ [] -> 0+ (x : xs) -> fst (foldl' better x xs)+ where+ better acc@(_, av) cur@(_, cv)+ | cv > av = cur -- 厳密に大きい確率のときだけ更新。+ | otherwise = acc -- 同点は据置き = 昇順 key で先に来た小さい index が勝つ。++-- ---------------------------------------------------------------------------+-- Best split: per-feature O(n log n) sweep with running counts+-- ---------------------------------------------------------------------------++bestSplitV+ :: DTConfig+ -> LA.Matrix Double+ -> VU.Vector Int+ -> VU.Vector Int+ -> Maybe (Int, Double, Double)+bestSplitV _cfg x y idx+ | VU.length idx < 2 = Nothing+ | otherwise =+ let !p = LA.cols x+ best = foldr step Nothing [0 .. p - 1]+ step i acc =+ case bestSplitFeature x y idx i of+ Nothing -> acc+ Just (thr, g) ->+ case acc of+ Nothing -> Just (i, thr, g)+ Just (_, _, gPrev) | g > gPrev -> Just (i, thr, g)+ | otherwise -> acc+ in best++-- | Per-feature best split on the index subset. Returns @Just (thr,+-- gain)@ where @gain@ is the impurity reduction (parent − weighted+-- children); negative or zero means no useful split was found.+bestSplitFeature+ :: LA.Matrix Double+ -> VU.Vector Int+ -> VU.Vector Int+ -> Int+ -> Maybe (Double, Double)+bestSplitFeature x y idx feat = runST $ do+ let !n = VU.length idx+ -- Build (value, label) pairs for this subset and sort by value.+ let valOf i = LA.atIndex x (i, feat)+ lab i = y VU.! i+ pairs <- VUM.new n+ let fill !k+ | k == n = pure ()+ | otherwise = do+ let !i = VU.unsafeIndex idx k+ VUM.unsafeWrite pairs k (valOf i, lab i)+ fill (k + 1)+ fill 0+ Intro.sortBy (\a b -> compare (fst a) (fst b)) pairs+ pairsF <- VU.unsafeFreeze pairs++ -- Determine the number of distinct classes within this subset.+ let labels = VU.map snd pairsF+ let !numClasses = 1 + VU.maximum labels -- labels are non-negative++ -- Right counts start with all labels.+ rightCounts <- VUM.replicate numClasses (0 :: Int)+ let initRight !k+ | k == n = pure ()+ | otherwise = do+ let !c = VU.unsafeIndex labels k+ old <- VUM.unsafeRead rightCounts c+ VUM.unsafeWrite rightCounts c (old + 1)+ initRight (k + 1)+ initRight 0+ leftCounts <- VUM.replicate numClasses (0 :: Int)++ let parentImp = giniFromIntCountsRO numClasses (VU.toList (VU.map snd pairsF))++ -- Sweep through sorted pairs, moving sample i to the left side and+ -- evaluating split between i and i+1 only when value changes.+ let sweep !k !bestThr !bestGain+ | k >= n - 1 = pure (bestThr, bestGain)+ | otherwise = do+ let (v_k, c_k) = VU.unsafeIndex pairsF k+ (v_k1, _) = VU.unsafeIndex pairsF (k + 1)+ -- Move sample k to left.+ lOld <- VUM.unsafeRead leftCounts c_k+ VUM.unsafeWrite leftCounts c_k (lOld + 1)+ rOld <- VUM.unsafeRead rightCounts c_k+ VUM.unsafeWrite rightCounts c_k (rOld - 1)+ -- Skip threshold if values equal — splitting equal+ -- samples is meaningless.+ if v_k == v_k1+ then sweep (k + 1) bestThr bestGain+ else do+ let !thr = (v_k + v_k1) / 2+ !nL = k + 1+ !nR = n - nL+ gL <- giniMutable leftCounts numClasses nL+ gR <- giniMutable rightCounts numClasses nR+ let !nD = fromIntegral n :: Double+ !child = (fromIntegral nL * gL + fromIntegral nR * gR) / nD+ !gain = parentImp - child+ if gain > bestGain+ then sweep (k + 1) thr gain+ else sweep (k + 1) bestThr bestGain+ (thr, gain) <- sweep 0 0 (negate (1.0 / 0.0))+ pure $ if gain == negate (1.0 / 0.0)+ then Nothing+ else Just (thr, gain)+ where+ -- Compute Gini from a mutable Int counts vector + total n.+ giniMutable counts numClasses nTot+ | nTot == 0 = pure 0+ | otherwise = do+ let !nD = fromIntegral nTot :: Double+ loop !i !acc+ | i == numClasses = pure (1 - acc)+ | otherwise = do+ c <- VUM.unsafeRead counts i+ let !p = fromIntegral c / nD+ loop (i + 1) (acc + p * p)+ loop 0 0++-- | Read-only Gini from a list of class labels (used once per node+-- for the parent impurity baseline).+giniFromIntCountsRO :: Int -> [Int] -> Double+giniFromIntCountsRO numClasses labels =+ let !n = fromIntegral (length labels) :: Double+ counts = foldl' (\m c -> Map.insertWith (+) c (1 :: Double) m)+ Map.empty labels+ _ = numClasses -- silence unused+ in 1 - sum [ (c / n) ^ (2 :: Int) | c <- Map.elems counts ]++-- ---------------------------------------------------------------------------+-- Predict+-- ---------------------------------------------------------------------------++-- | Predict the majority class label for one sample.+predictDT :: DTree -> [Double] -> Int+predictDT DLeaf{dlMajority = m} _ = m+predictDT DNode{dnFeature = i, dnThr = thr, dnLeft = l, dnRight = r} x+ | x !! i <= thr = predictDT l x+ | otherwise = predictDT r x++-- | Predict class probabilities for one sample.+predictDTProbs :: DTree -> [Double] -> Map.Map Int Double+predictDTProbs DLeaf{dlClassProbs = p} _ = p+predictDTProbs DNode{dnFeature = i, dnThr = thr, dnLeft = l, dnRight = r} x+ | x !! i <= thr = predictDTProbs l x+ | otherwise = predictDTProbs r x++-- ---------------------------------------------------------------------------+-- Text export (R print.rpart 相当)+-- ---------------------------------------------------------------------------++-- | [日本語]: 決定木のルールを R @print.rpart@ 形式のテキストで出力する。+--+-- R の rpart オブジェクトを @print@ したときと同じ体裁:+--+-- @+-- n= <total>+--+-- node), split, n, loss, yval, (yprob)+-- * denotes terminal node+--+-- 1) root 12 8 setosa (0.3333 0.3333 0.3333)+-- 2) petal_width< 0.80 4 0 setosa (1.0000 0.0000 0.0000) *+-- 3) petal_width>=0.80 8 4 versicolor (0.0000 0.5000 0.5000)+-- 6) petal_width< 1.65 4 0 versicolor (0.0000 1.0000 0.0000) *+-- 7) petal_width>=1.65 4 0 virginica (0.0000 0.0000 1.0000) *+-- @+--+-- 各行 = @<node#>) <split> <n> <loss> <yval> (<yprob…>) [*]@。+-- ノード番号は R 同様 root=1・子は @2k@/@2k+1@。 @loss@ = 誤分類数+-- (n − 多数決クラス件数)、 @yval@ = 予測クラス、 @yprob@ = 木に現れる全クラスの+-- 確率 (クラス index 昇順)、 @*@ = 終端 (葉)。 分岐は R の固定幅表記に忠実に+-- 左 = @name< thr@ (≤・条件成立)、 右 = @name>=thr@ とする (dtreeToDag と同じ+-- 左 ≤ / 右 > 慣例)。+--+-- 第 1 引数 = 特徴量名、 第 2 引数 = クラス名 (yval に使う factor 水準)。+-- いずれも index に対し長さ不足・空文字なら @f{i}@ / 生の整数へフォールバックする+-- (行列 fit で名無しの木でも動く)。 各ノードに載せた n / gini / クラス分布+-- から純粋計算し、 予測 (predict) の数値には非依存。+--+-- 高レベル版 — 'DTFit' からノード規則テキストを出す ('df |-> decisionTree' の返り値+-- をそのまま渡せる)。 名前を手渡ししたい低レベルは 'printRpartRaw'。+--+-- [English]: Outputs a decision tree's rules as text in R @print.rpart@+-- format.+--+-- The same layout as when an R rpart object is @print@-ed:+--+-- @+-- n= <total>+--+-- node), split, n, loss, yval, (yprob)+-- * denotes terminal node+--+-- 1) root 12 8 setosa (0.3333 0.3333 0.3333)+-- 2) petal_width< 0.80 4 0 setosa (1.0000 0.0000 0.0000) *+-- 3) petal_width>=0.80 8 4 versicolor (0.0000 0.5000 0.5000)+-- 6) petal_width< 1.65 4 0 versicolor (0.0000 1.0000 0.0000) *+-- 7) petal_width>=1.65 4 0 virginica (0.0000 0.0000 1.0000) *+-- @+--+-- Each line = @<node#>) <split> <n> <loss> <yval> (<yprob…>) [*]@.+-- Node numbers follow R's convention: root=1, children @2k@\/@2k+1@. @loss@+-- = the misclassification count (n − the majority class count), @yval@ =+-- the predicted class, @yprob@ = the probabilities of all classes appearing+-- in the tree (ascending class index), @*@ = terminal (leaf). Splits follow+-- R's fixed-width notation faithfully: left = @name< thr@ (≤, condition+-- holds), right = @name>=thr@ (the same left ≤ \/ right > convention as+-- dtreeToDag).+--+-- The first argument = feature names, the second = class names (the factor+-- levels used for yval). For both, an index with insufficient length or an+-- empty string falls back to @f{i}@ \/ the raw integer (works even for a+-- tree with no names from a matrix fit). Computed purely from the n \/ gini+-- \/ class distribution carried at each node, independent of the predicted+-- (predict) values.+--+-- The high-level version — outputs node rule text from a 'DTFit' (the+-- return value of @df |-> decisionTree@ can be passed directly). The+-- low-level version for passing names explicitly is 'printRpartRaw'.+printRpart :: DTFit -> Text+printRpart (DTFit tree feats classes) = printRpartRaw feats classes tree++-- | [日本語]: 行列 fit 用の低レベル版 — 特徴量名・クラス名を明示的に渡す。+-- [English]: The low-level version for matrix fits — passes feature names+-- and class names explicitly.+printRpartRaw :: [Text] -> [Text] -> DTree -> Text+printRpartRaw featNames classNames tree =+ T.intercalate "\n" (header ++ go 1 0 "root" tree)+ where+ classes = Map.keys (labelSet tree) -- 木に現れる全クラス (昇順)。+ header =+ [ "n= " <> tShow (nodeN tree)+ , ""+ , "node), split, n, loss, yval, (yprob)"+ , " * denotes terminal node"+ , "" ]++ go :: Int -> Int -> Text -> DTree -> [Text]+ go num d split node =+ let n = nodeN node+ probs = nodeProbs node+ maj = nodeMajority node+ loss = n - round (Map.findWithDefault 0 maj probs * fromIntegral n) :: Int+ yprob = "(" <> T.intercalate " "+ [ fmt4 (Map.findWithDefault 0 c probs) | c <- classes ] <> ")"+ term = case node of DLeaf{} -> " *"; _ -> ""+ line = T.concat (replicate d " ") <> tShow num <> ") " <> split+ <> " " <> tShow n <> " " <> tShow loss <> " " <> classLabel maj+ <> " " <> yprob <> term+ in case node of+ DLeaf{} -> [line]+ DNode f thr l r _ _ _ _ ->+ let fn = featName f+ lb = fn <> "< " <> fmt2 thr+ rb = fn <> ">=" <> fmt2 thr+ in line : go (2 * num) (d + 1) lb l ++ go (2 * num + 1) (d + 1) rb r++ featName i = pick i featNames ("f" <> tShow i)+ classLabel i = pick i classNames (tShow i)+ pick i xs dflt = case drop i xs of+ (nm : _) | not (T.null nm) -> nm+ _ -> dflt++ tShow = T.pack . show+ fmt2 x = T.pack (showFFloat (Just 2) x "")+ fmt4 x = T.pack (showFFloat (Just 4) x "")++-- | [日本語]: 木に現れる全クラス label を集めた集合 (値は () のダミー)。 'Map.keys' で昇順。+-- [English]: The set of all class labels appearing in the tree (values are+-- a dummy @()@). Ascending via 'Map.keys'.+labelSet :: DTree -> Map.Map Int ()+labelSet (DLeaf p m _ _) = Map.insert m () (() <$ p)+labelSet (DNode _ _ l r _ _ p m) =+ Map.unions [Map.insert m () (() <$ p), labelSet l, labelSet r]++-- ノードアクセサ (葉 / 分岐 共通)。+nodeN :: DTree -> Int+nodeN (DLeaf _ _ n _) = n+nodeN (DNode _ _ _ _ n _ _ _) = n++nodeProbs :: DTree -> Map.Map Int Double+nodeProbs (DLeaf p _ _ _) = p+nodeProbs (DNode _ _ _ _ _ _ p _) = p++nodeMajority :: DTree -> Int+nodeMajority (DLeaf _ m _ _) = m+nodeMajority (DNode _ _ _ _ _ _ _ m) = m++-- Silence unused-import warning for V (keeps import slot for future+-- variants without re-touching imports).+_unused :: V.Vector Int -> Int+_unused = V.length
+ src/Hanalyze/Model/Discriminant.hs view
@@ -0,0 +1,256 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE BangPatterns #-}+-- |+-- Module : Hanalyze.Model.Discriminant+-- Description : 判別分析 (Linear / Quadratic Discriminant Analysis)+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- [日本語]: 判別分析 (Linear / Quadratic Discriminant Analysis)。+--+-- 連続説明変数で複数クラスを判別する古典的手法。+--+-- - 'LDA': 全クラスで共分散行列を共通 (pooled) と仮定 → 線形決定境界+-- - 'QDA': クラスごとに共分散行列が異なる → 二次決定境界+--+-- 予測は class-conditional 密度 × prior の対数 (log-posterior) を比較。+-- 数値安定化のため Cholesky 分解経由で log-determinant + Mahalanobis 距離を+-- 計算する。 hmatrix Vector / Matrix 演算で完結 (list 化禁止)。+--+-- [English]: Discriminant analysis (Linear \/ Quadratic Discriminant+-- Analysis).+--+-- Classic methods for discriminating between multiple classes using+-- continuous predictors.+--+-- - 'LDA': assumes a common (pooled) covariance matrix across all+-- classes → a linear decision boundary+-- - 'QDA': allows a different covariance matrix per class → a+-- quadratic decision boundary+--+-- Prediction compares the log of class-conditional density × prior+-- (log-posterior). For numerical stability, the log-determinant and+-- Mahalanobis distance are computed via Cholesky decomposition. Stays+-- entirely within hmatrix Vector \/ Matrix operations (no list conversion).+module Hanalyze.Model.Discriminant+ ( DiscriminantMethod (..)+ , DiscriminantFit (..)+ , fitLDA+ , fitQDA+ , predictDiscriminant+ ) where++import qualified Data.Vector as V+import qualified Numeric.LinearAlgebra as LA+import Data.List (nub, sort)+import Data.Text (Text)+import qualified Data.Text as T++-- ===========================================================================+-- 型+-- ===========================================================================++data DiscriminantMethod = LDA | QDA deriving (Show, Eq)++data DiscriminantFit = DiscriminantFit+ { dfMeans :: !(LA.Matrix Double)+ -- ^ [日本語]: K × p、 各クラスの平均ベクトル+ -- [English]: K × p, each class's mean vector+ , dfCovariance :: !(LA.Matrix Double)+ -- ^ [日本語]: LDA: pooled covariance (p × p)、 QDA: 空 (使わず、 dfCovariances を見る)+ -- [English]: LDA: the pooled covariance (p × p); QDA: empty (unused, see dfCovariances)+ , dfCovariances :: ![LA.Matrix Double]+ -- ^ [日本語]: QDA: クラス別 covariance (K matrices)、 LDA: 空+ -- [English]: QDA: per-class covariance (K matrices); LDA: empty+ , dfPriors :: !(LA.Vector Double)+ -- ^ [日本語]: クラス事前確率 (length K、 sum = 1)+ -- [English]: Class prior probabilities (length K, sum = 1)+ , dfClasses :: !(LA.Vector Double)+ -- ^ [日本語]: クラス label (sorted、 length K、 Int を Double で保持)+ -- [English]: Class labels (sorted, length K, Int held as Double)+ , dfMethod :: !DiscriminantMethod+ } deriving (Show)++-- ===========================================================================+-- 公開関数+-- ===========================================================================++-- | [日本語]: LDA fit: pooled covariance、 線形判別。+-- [English]: LDA fit: pooled covariance, linear discrimination.+fitLDA :: LA.Matrix Double -- ^ [日本語]: X (n × p) [English]: X (n × p)+ -> V.Vector Int -- ^ [日本語]: y (n)、 整数クラスラベル [English]: y (n), integer class labels+ -> Either Text DiscriminantFit+fitLDA x y+ | LA.rows x /= V.length y =+ Left "fitLDA: X rows and y length mismatch"+ | LA.rows x < 2 =+ Left "fitLDA: need at least 2 observations"+ | length classIds < 2 =+ Left "fitLDA: need at least 2 distinct classes"+ | otherwise =+ let (means, sigmaP, priors) = pooledStats x y classIds+ in Right DiscriminantFit+ { dfMeans = means+ , dfCovariance = sigmaP+ , dfCovariances = []+ , dfPriors = priors+ , dfClasses = LA.fromList (map fromIntegral classIds)+ , dfMethod = LDA+ }+ where+ classIds = sort (nub (V.toList y))++-- | [日本語]: QDA fit: クラス別 covariance。+-- [English]: QDA fit: per-class covariance.+fitQDA :: LA.Matrix Double -> V.Vector Int -> Either Text DiscriminantFit+fitQDA x y+ | LA.rows x /= V.length y =+ Left "fitQDA: X rows and y length mismatch"+ | LA.rows x < 2 =+ Left "fitQDA: need at least 2 observations"+ | length classIds < 2 =+ Left "fitQDA: need at least 2 distinct classes"+ | minimum classCounts < LA.cols x + 1 =+ Left (T.pack ("fitQDA: each class needs ≥ p+1 = "+ <> show (LA.cols x + 1) <> " observations (got min "+ <> show (minimum classCounts) <> ")"))+ | otherwise =+ let (means, covs, priors) = perClassStats x y classIds+ in Right DiscriminantFit+ { dfMeans = means+ , dfCovariance = LA.fromLists [[]]+ , dfCovariances = covs+ , dfPriors = priors+ , dfClasses = LA.fromList (map fromIntegral classIds)+ , dfMethod = QDA+ }+ where+ classIds = sort (nub (V.toList y))+ classCounts = [length [i | i <- [0 .. V.length y - 1], y V.! i == c]+ | c <- classIds]++-- | [日本語]: 予測。 返り値 = (予測ラベル長 m, posterior 行列 m × K)。+-- [English]: Prediction. Returns (predicted labels, length m; posterior+-- matrix, m × K).+predictDiscriminant+ :: DiscriminantFit+ -> LA.Matrix Double -- ^ [日本語]: X_new (m × p) [English]: X_new (m × p)+ -> (V.Vector Int, LA.Matrix Double)+predictDiscriminant fit xNew =+ let m = LA.rows xNew+ k = LA.size (dfPriors fit)+ classLabels = LA.toList (dfClasses fit)+ -- 各サンプル × 各クラスの log-posterior を計算+ logPostMat = LA.fromLists+ [ [ logPosterior fit (LA.flatten (xNew LA.? [i])) j+ | j <- [0 .. k - 1] ]+ | i <- [0 .. m - 1] ]+ -- 各行で argmax → ラベル予測+ predLabels = V.fromList+ [ let row = LA.toList (logPostMat LA.! i)+ maxIdx = snd (maximum (zip row [0 ..]))+ in round (classLabels !! maxIdx :: Double) :: Int+ | i <- [0 .. m - 1] ]+ -- posterior = exp(log-post) / Σ exp(log-post) (各行で normalize)+ posteriorMat = LA.fromLists+ [ let row = LA.toList (logPostMat LA.! i)+ maxLP = maximum row+ expRow = map (\x -> exp (x - maxLP)) row+ s = sum expRow+ in if s > 0 then map (/ s) expRow else expRow+ | i <- [0 .. m - 1] ]+ in (predLabels, posteriorMat)++-- ===========================================================================+-- 内部 helper+-- ===========================================================================++-- | [日本語]: log p(class=j) + log f(x | class=j)+-- - LDA: − 0.5 (x − μ_j)ᵀ Σ_p⁻¹ (x − μ_j) + log π_j (定数項を省略)+-- - QDA: − 0.5 log |Σ_j| − 0.5 (x − μ_j)ᵀ Σ_j⁻¹ (x − μ_j) + log π_j+-- [English]: log p(class=j) + log f(x | class=j)+-- - LDA: − 0.5 (x − μ_j)ᵀ Σ_p⁻¹ (x − μ_j) + log π_j (omitting the constant term)+-- - QDA: − 0.5 log |Σ_j| − 0.5 (x − μ_j)ᵀ Σ_j⁻¹ (x − μ_j) + log π_j+logPosterior :: DiscriminantFit -> LA.Vector Double -> Int -> Double+logPosterior fit x j =+ let mu_j = LA.flatten (dfMeans fit LA.? [j])+ diff = x - mu_j+ logPi = log (LA.atIndex (dfPriors fit) j)+ in case dfMethod fit of+ LDA ->+ let sigInvDiff = case LA.linearSolve (dfCovariance fit)+ (LA.asColumn diff) of+ Just m -> LA.flatten m+ Nothing -> diff -- singular fallback+ mahal = LA.sumElements (diff * sigInvDiff)+ in -0.5 * mahal + logPi+ QDA ->+ let sigma_j = dfCovariances fit !! j+ logDet = log (max 1e-300 (LA.det sigma_j))+ sigInvDiff = case LA.linearSolve sigma_j (LA.asColumn diff) of+ Just m -> LA.flatten m+ Nothing -> diff+ mahal = LA.sumElements (diff * sigInvDiff)+ in -0.5 * logDet - 0.5 * mahal + logPi++-- | [日本語]: 各クラスの平均と pooled covariance + prior を計算。+-- [English]: Compute each class's mean along with the pooled covariance + prior.+pooledStats+ :: LA.Matrix Double -> V.Vector Int -> [Int]+ -> (LA.Matrix Double, LA.Matrix Double, LA.Vector Double)+pooledStats x y classIds =+ let n = LA.rows x+ p = LA.cols x+ nD = fromIntegral n :: Double+ classRows c = [i | i <- [0 .. n - 1], y V.! i == c]+ classN c = fromIntegral (length (classRows c)) :: Double+ means = LA.fromRows+ [ let rs = classRows c+ xc = x LA.? rs+ n_c = fromIntegral (length rs) :: Double+ colSum j = LA.sumElements (xc LA.¿ [j])+ in LA.fromList [ colSum j / n_c | j <- [0 .. p - 1] ]+ | c <- classIds ]+ -- pooled covariance: Σ_p = Σ_c (n_c - 1) S_c / (n - K)+ sigmaP =+ let k = length classIds+ sumS = foldr (+) (LA.konst 0 (p, p))+ [ let rs = classRows c+ xc = x LA.? rs+ mu = LA.flatten (means LA.? [idx])+ centered = xc - LA.fromRows (replicate (length rs) mu)+ in LA.tr centered LA.<> centered -- (n_c - 1) S_c+ | (idx, c) <- zip [0 ..] classIds ]+ in LA.scale (1 / fromIntegral (n - k)) sumS+ priors = LA.fromList [ classN c / nD | c <- classIds ]+ in (means, sigmaP, priors)++-- | [日本語]: クラス別 mean + cov + prior。+-- [English]: Per-class mean + covariance + prior.+perClassStats+ :: LA.Matrix Double -> V.Vector Int -> [Int]+ -> (LA.Matrix Double, [LA.Matrix Double], LA.Vector Double)+perClassStats x y classIds =+ let n = LA.rows x+ p = LA.cols x+ nD = fromIntegral n :: Double+ classRows c = [i | i <- [0 .. n - 1], y V.! i == c]+ means = LA.fromRows+ [ let rs = classRows c+ xc = x LA.? rs+ n_c = fromIntegral (length rs) :: Double+ colSum j = LA.sumElements (xc LA.¿ [j])+ in LA.fromList [ colSum j / n_c | j <- [0 .. p - 1] ]+ | c <- classIds ]+ covs =+ [ let rs = classRows c+ xc = x LA.? rs+ n_c = fromIntegral (length rs) :: Double+ mu = LA.flatten (means LA.? [idx])+ centered = xc - LA.fromRows (replicate (length rs) mu)+ in LA.scale (1 / (n_c - 1)) (LA.tr centered LA.<> centered)+ | (idx, c) <- zip [0 ..] classIds ]+ priors = LA.fromList+ [ fromIntegral (length (classRows c)) / nD | c <- classIds ]+ _ = p -- silence+ in (means, covs, priors)
+ src/Hanalyze/Model/FDA.hs view
@@ -0,0 +1,299 @@+-- |+-- Module : Hanalyze.Model.FDA+-- Description : 関数データ解析 (Functional Data Analysis, FDA)+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- [日本語]: Functional Data Analysis (FDA)。+--+-- センサ / プロセス時系列を __1 観測 = 1 関数__として扱う Ramsay-Silverman+-- FDA の基礎機能。 個別の生時系列ではなく、 関数空間上の主成分 / 回帰を+-- 直接扱う。+--+-- ## 構成+--+-- - 'smoothBasis': 各サンプルを B-spline basis + 二階差分 (P-spline) penalty+-- で smooth fit → 'FunctionalSample' (basis 係数表現)+-- - 'functionalPCA': basis 係数行列の covariance に PCA、 関数主成分+-- - 'fLM': functional linear regression @y_i = α + ∫ x_i(t) β(t) dt + ε@+--+-- 既存 'Hanalyze.Model.Spline' の @bsplineBasis@ を basis 生成として再利用。+-- Fourier basis は将来拡張 (現状は対象外)。+--+-- Reference: Ramsay & Silverman (2005) "Functional Data Analysis" 2nd ed.+-- Eilers-Marx (1996) "Flexible smoothing with B-splines and penalties" —+-- P-spline 二階差分 penalty。+--+-- [English]: Functional Data Analysis (FDA).+--+-- Basic Ramsay-Silverman FDA functionality that treats sensor\/process+-- time series as __one observation = one function__. Rather than+-- individual raw time series, it works directly with principal components+-- \/ regression over function space.+--+-- == Structure+--+-- - 'smoothBasis': smooth-fits each sample with a B-spline basis + a+-- second-difference (P-spline) penalty → 'FunctionalSample' (basis+-- coefficient representation)+-- - 'functionalPCA': PCA on the covariance of the basis coefficient+-- matrix, giving functional principal components+-- - 'fLM': functional linear regression @y_i = α + ∫ x_i(t) β(t) dt + ε@+--+-- Reuses the existing 'Hanalyze.Model.Spline''s @bsplineBasis@ for+-- basis generation. A Fourier basis is a future extension (out of scope+-- for now).+--+-- Reference: Ramsay & Silverman (2005) "Functional Data Analysis" 2nd ed.+-- Eilers-Marx (1996) "Flexible smoothing with B-splines and penalties" —+-- the P-spline second-difference penalty.+module Hanalyze.Model.FDA+ ( Basis (..)+ , FunctionalSample (..)+ , smoothBasis+ , evalFunctional+ -- * FPCA+ , FunctionalPCA (..)+ , functionalPCA+ -- * Functional Linear Regression+ , FLMResult (..)+ , fLM+ ) where++import qualified Numeric.LinearAlgebra as LA+import qualified Data.Vector as V+import qualified Hanalyze.Model.Spline as Sp++-- ---------------------------------------------------------------------------+-- 基底+-- ---------------------------------------------------------------------------++-- | [日本語]: basis 種別。 現在は B-spline のみ実装、 Fourier は将来拡張。+-- [English]: Basis kind. Currently only B-spline is implemented; Fourier+-- is a future extension.+data Basis+ = BSpline !Int ![Double] -- ^ [日本語]: (degree, interior knots、 境界含む) [English]: (degree, interior knots, including boundary)+ deriving (Show)++-- | [日本語]: smooth した関数表現 (basis 係数 + 元 grid)。+-- [English]: A smoothed functional representation (basis coefficients + original grid).+data FunctionalSample = FunctionalSample+ { fsCoef :: !(LA.Vector Double) -- ^ [日本語]: basis 係数 [English]: Basis coefficients+ , fsBasis :: !Basis+ , fsGrid :: !(LA.Vector Double) -- ^ [日本語]: 元の時間 grid (eval 用) [English]: The original time grid (for evaluation)+ } deriving (Show)++-- ---------------------------------------------------------------------------+-- 33-A1: smoothBasis (P-spline)+-- ---------------------------------------------------------------------------++-- | [日本語]: 複数サンプルを basis + roughness penalty で smooth fit。+--+-- 解: @c = (BᵀB + λ DᵀD)⁻¹ Bᵀy@ (= P-spline、 D は二階差分作用素)。+-- @λ → 0@ で interpolate、 @λ → ∞@ で over-smooth (≈ 一次関数)。+--+-- 入力 @y@ は @n_samples × n_grid@、 各行が 1 サンプル。+--+-- [English]: Smooth-fit multiple samples with a basis + roughness penalty.+--+-- Solution: @c = (BᵀB + λ DᵀD)⁻¹ Bᵀy@ (a P-spline, where D is the+-- second-difference operator). @λ → 0@ interpolates; @λ → ∞@ over-smooths+-- (≈ a linear function).+--+-- Input @y@ is @n_samples × n_grid@, with each row a single sample.+smoothBasis+ :: Basis -- ^ [日本語]: basis (B-spline) [English]: The basis (B-spline)+ -> Double -- ^ [日本語]: roughness penalty @λ@ [English]: Roughness penalty @λ@+ -> LA.Vector Double -- ^ [日本語]: 時間 grid @t@ (長さ @n_grid@) [English]: Time grid @t@ (length @n_grid@)+ -> LA.Matrix Double -- ^ [日本語]: 観測 @y@ (@n_samples × n_grid@) [English]: Observations @y@ (@n_samples × n_grid@)+ -> [FunctionalSample]+smoothBasis basis@(BSpline deg intKnots) lambda tGrid yMat =+ let tV = V.fromList (LA.toList tGrid)+ bMat = Sp.bsplineBasis deg intKnots tV -- n_grid × d+ d = LA.cols bMat+ btb = LA.tr bMat LA.<> bMat+ penalty = diff2Penalty d+ reg = btb + LA.scale lambda penalty+ -- 各行 (= 1 サンプル) について解く: c = (BᵀB+λΩ)⁻¹ Bᵀy_i+ btY = LA.tr bMat LA.<> LA.tr yMat -- d × n_samples+ cMat = reg LA.<\> btY -- d × n_samples+ n = LA.rows yMat+ in [ FunctionalSample+ { fsCoef = LA.flatten (cMat LA.¿ [i])+ , fsBasis = basis+ , fsGrid = tGrid+ }+ | i <- [0 .. n - 1] ]++-- | [日本語]: smooth した関数を任意 grid で評価。+-- [English]: Evaluate the smoothed function on an arbitrary grid.+evalFunctional :: FunctionalSample -> LA.Vector Double -> LA.Vector Double+evalFunctional fs tNew =+ case fsBasis fs of+ BSpline deg intKnots ->+ let tV = V.fromList (LA.toList tNew)+ bM = Sp.bsplineBasis deg intKnots tV+ in bM LA.#> fsCoef fs++-- | [日本語]: 二階差分 penalty 行列 @DᵀD@ (= 連続二階微分の量を有限差分で近似)。+-- @D@ は @(d-2) × d@、 @D_{i,j} = 1 if j=i、 -2 if j=i+1、 1 if j=i+2@。+--+-- [English]: The second-difference penalty matrix @DᵀD@ (approximating+-- the continuous second derivative via finite differences). @D@ is+-- @(d-2) × d@, with @D_{i,j} = 1 if j=i, -2 if j=i+1, 1 if j=i+2@.+diff2Penalty :: Int -> LA.Matrix Double+diff2Penalty d+ | d <= 2 = LA.konst 0 (d, d)+ | otherwise =+ let dM = LA.fromLists+ [ [ if j == i then 1+ else if j == i + 1 then -2+ else if j == i + 2 then 1+ else 0+ | j <- [0 .. d - 1] ]+ | i <- [0 .. d - 3] ]+ in LA.tr dM LA.<> dM++-- ---------------------------------------------------------------------------+-- 33-A2: Functional PCA+-- ---------------------------------------------------------------------------++data FunctionalPCA = FunctionalPCA+ { fpcaScores :: !(LA.Matrix Double) -- ^ [日本語]: n × K (各サンプルの主成分得点) [English]: n × K (each sample's principal-component scores)+ , fpcaEigenfn :: !(LA.Matrix Double) -- ^ [日本語]: K × n_grid (主成分関数を grid 上で評価) [English]: K × n_grid (principal-component functions evaluated on the grid)+ , fpcaEigenvalues :: !(LA.Vector Double) -- ^ [日本語]: length K (降順) [English]: length K (descending)+ , fpcaMeanFn :: !(LA.Vector Double) -- ^ [日本語]: length n_grid (平均関数) [English]: length n_grid (mean function)+ } deriving (Show)++-- | [日本語]: basis 係数行列の covariance に PCA。 簡略実装として basis 係数空間で+-- PCA を行い、 主成分関数を grid 上で評価して返す (= basis が直交近似で+-- ある前提)。 厳密版は basis mass matrix @J = ∫ B B^T@ で重み付き SVD が+-- 必要だが、 B-spline + dense grid なら直交近似で十分実用に耐える。+--+-- [English]: PCA on the covariance of the basis coefficient matrix. As a+-- simplified implementation, this runs PCA in the basis coefficient+-- space and evaluates the principal-component functions on the grid+-- (assuming the basis is approximately orthogonal). An exact version+-- would need a weighted SVD using the basis mass matrix @J = ∫ B B^T@,+-- but for B-spline + a dense grid the orthogonal approximation is+-- accurate enough for practical use.+functionalPCA+ :: Int -- ^ [日本語]: 主成分数 K [English]: Number of principal components K+ -> [FunctionalSample]+ -> FunctionalPCA+functionalPCA k samples =+ let cMat = LA.fromColumns (map fsCoef samples) -- d × n+ n = LA.cols cMat+ d = LA.rows cMat+ mu = LA.scale (1 / fromIntegral n)+ (cMat LA.#> LA.konst 1 n)+ cCentered = cMat - LA.asColumn mu -- d × n+ cov = LA.scale (1 / fromIntegral (max 1 (n - 1)))+ (cCentered LA.<> LA.tr cCentered) -- d × d+ (eigVals, eigVecs) = LA.eigSH (LA.trustSym cov)+ -- hmatrix eigSH は降順で返す+ kEff = min k d+ topVecs = eigVecs LA.¿ [0 .. kEff - 1] -- d × K+ topVals = LA.subVector 0 kEff eigVals+ -- score: K × n、 各列 = 係数空間での座標+ scoresT = LA.tr topVecs LA.<> cCentered+ -- 主成分関数を grid 上で評価+ sampleBasis = fsBasis (head samples)+ tGrid = fsGrid (head samples)+ eigFn = case sampleBasis of+ BSpline deg intKnots ->+ let bM = Sp.bsplineBasis deg intKnots+ (V.fromList (LA.toList tGrid)) -- n_grid × d+ in LA.tr (bM LA.<> topVecs) -- K × n_grid+ meanFn = case sampleBasis of+ BSpline deg intKnots ->+ let bM = Sp.bsplineBasis deg intKnots+ (V.fromList (LA.toList tGrid))+ in bM LA.#> mu+ in FunctionalPCA+ { fpcaScores = LA.tr scoresT+ , fpcaEigenfn = eigFn+ , fpcaEigenvalues = topVals+ , fpcaMeanFn = meanFn+ }++-- ---------------------------------------------------------------------------+-- 33-A3: Functional Linear Regression+-- ---------------------------------------------------------------------------++data FLMResult = FLMResult+ { flmAlpha :: !Double -- ^ [日本語]: intercept [English]: Intercept+ , flmBetaFn :: !(LA.Vector Double) -- ^ [日本語]: β(t) を共通 grid 上で評価 [English]: β(t) evaluated on the common grid+ , flmFitted :: !(LA.Vector Double) -- ^ [日本語]: ŷ_i (length n) [English]: ŷ_i (length n)+ , flmR2 :: !Double+ } deriving (Show)++-- | [日本語]: Functional linear regression: @y_i = α + ∫ x_i(t) β(t) dt + ε@.+--+-- @β(t)@ を同じ basis で展開: @β(t) = B(t)^T γ@。 すると+-- @∫ x_i(t) β(t) dt = c_i^T J γ@ ここで @J = ∫ B(t) B(t)^T dt@ (mass matrix)。+-- 設計行列 @[1, c_i^T J]@ で OLS + 任意の roughness penalty。+--+-- mass matrix @J@ は trapezoidal 積分で近似:+-- @J ≈ Δt · B^T diag(w) B@ where @w@ は等間隔積分重み (端点 0.5、 内点 1)。+--+-- [English]: Functional linear regression: @y_i = α + ∫ x_i(t) β(t) dt + ε@.+--+-- Expand @β(t)@ in the same basis: @β(t) = B(t)^T γ@. Then+-- @∫ x_i(t) β(t) dt = c_i^T J γ@ where @J = ∫ B(t) B(t)^T dt@ (the mass+-- matrix). Runs OLS on the design matrix @[1, c_i^T J]@ plus an optional+-- roughness penalty.+--+-- The mass matrix @J@ is approximated by trapezoidal integration:+-- @J ≈ Δt · B^T diag(w) B@ where @w@ is the evenly-spaced integration+-- weight (0.5 at the endpoints, 1 at interior points).+fLM+ :: [FunctionalSample] -- ^ [日本語]: X_i(t) [English]: X_i(t)+ -> LA.Vector Double -- ^ [日本語]: y (n samples) [English]: y (n samples)+ -> Double -- ^ [日本語]: λ (β(t) の二階差分 penalty) [English]: λ (second-difference penalty on β(t))+ -> FLMResult+fLM samples y lambda =+ let sample0 = head samples+ basis@(BSpline deg intKnots) = fsBasis sample0+ tGrid = fsGrid sample0+ tV = V.fromList (LA.toList tGrid)+ bM = Sp.bsplineBasis deg intKnots tV+ nGrid = LA.size tGrid+ -- trapezoidal 重み+ dt = if nGrid >= 2+ then (LA.atIndex tGrid (nGrid - 1) - LA.atIndex tGrid 0)+ / fromIntegral (nGrid - 1)+ else 1+ wVec = LA.fromList+ ([0.5] ++ replicate (max 0 (nGrid - 2)) 1.0 ++ [0.5])+ wScaled = LA.scale dt wVec+ -- mass matrix J = B^T diag(w) B (d × d)+ jMat = LA.tr bM LA.<> (LA.asColumn wScaled * bM)+ -- 設計行列: 各 i 行 = [1, c_i^T J] (length 1 + d)+ cMat = LA.fromRows (map fsCoef samples) -- n × d+ ciJ = cMat LA.<> jMat -- n × d+ n = LA.rows cMat+ xDes = LA.fromColumns+ (LA.konst 1 n : LA.toColumns ciJ) -- n × (1 + d)+ -- penalty: intercept は 0、 γ には二階差分 penalty+ d = LA.cols cMat+ pen = diff2Penalty d+ penFull = LA.diagBlock [LA.scalar 0, LA.scale lambda pen]+ reg = LA.tr xDes LA.<> xDes + penFull+ xty = LA.tr xDes LA.#> y+ coefs = LA.flatten (reg LA.<\> LA.asColumn xty)+ alpha = LA.atIndex coefs 0+ gamma = LA.subVector 1 d coefs+ yHat = xDes LA.#> coefs+ resid = y - yHat+ yMean = LA.sumElements y / fromIntegral n+ ssTot = LA.sumElements ((y - LA.scalar yMean) ^ (2 :: Int))+ ssRes = LA.sumElements (resid ^ (2 :: Int))+ r2 = if ssTot == 0 then 0 else 1 - ssRes / ssTot+ betaFn = bM LA.#> gamma+ in FLMResult+ { flmAlpha = alpha+ , flmBetaFn = betaFn+ , flmFitted = yHat+ , flmR2 = r2+ }
+ src/Hanalyze/Model/FitYByX.hs view
@@ -0,0 +1,135 @@+{-# LANGUAGE OverloadedStrings #-}+-- |+-- Module : Hanalyze.Model.FitYByX+-- Description : JMP "Fit Y by X" platform 相当の自動 dispatch wrapper+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- [日本語]: JMP \"Fit Y by X\" platform 相当の wrapper。+--+-- X / Y それぞれが連続 (Continuous) か カテゴリ (Categorical) かで+-- 適切な解析を自動 dispatch する:+--+-- @+-- X \\ Y | Continuous | Categorical+-- --------+----------------------+---------------------+-- Cont | 単回帰 (LM) | logistic GLM+-- Cat | one-way ANOVA | chi-square independence+-- @+--+-- canvas frontend で 「変数 2 つドラッグ → 自動分析」 を支える backend wrapper。+--+-- [English]: A wrapper equivalent to JMP's \"Fit Y by X\" platform.+--+-- Automatically dispatches to the appropriate analysis depending on+-- whether X \/ Y are each continuous or categorical:+--+-- @+-- X \\ Y | Continuous | Categorical+-- --------+----------------------+---------------------+-- Cont | simple regression (LM) | logistic GLM+-- Cat | one-way ANOVA | chi-square independence+-- @+--+-- The backend wrapper behind the canvas frontend's "drag 2 variables →+-- auto-analyze".+module Hanalyze.Model.FitYByX+ ( VarType (..)+ , FitYByXResult (..)+ , fitYByX+ ) where++import qualified Data.Vector as V+import qualified Numeric.LinearAlgebra as LA+import Data.List (nub, sort)+import Data.Text (Text)++import qualified Hanalyze.Model.Core as Core+import qualified Hanalyze.Model.LM as LM+import qualified Hanalyze.Model.GLM as GLM+import qualified Hanalyze.Stat.Test as ST++-- ===========================================================================+-- 型+-- ===========================================================================++data VarType+ = Continuous+ | Categorical+ deriving (Show, Eq)++data FitYByXResult+ = FitContCont !Core.FitResult+ -- ^ [日本語]: 単回帰: y = β₀ + β₁ x。 [English]: Simple regression: y = β₀ + β₁ x.+ | FitCatCont !ST.TestResult ![Double]+ -- ^ one-way ANOVA + group means (group order = sort.nub of x)+ | FitContCat !Core.FitResult+ -- ^ logistic GLM: P(Y=1) = sigmoid(β₀ + β₁ x)+ | FitCatCat !ST.TestResult+ -- ^ chi-square independence+ deriving (Show)++-- ===========================================================================+-- 公開 API+-- ===========================================================================++-- | [日本語]: X / Y の型に応じて適切な解析を dispatch する。+-- 入力は両方とも Double Vector。 Categorical の場合は整数値を Double 化+-- して渡す前提 (例: 0, 1, 2, ...)。+-- [English]: Dispatches to the appropriate analysis depending on the+-- types of X \/ Y. Both inputs are Double Vectors; for Categorical,+-- integer values are assumed to be passed converted to Double (e.g.+-- 0, 1, 2, ...).+fitYByX+ :: VarType -> VarType+ -> LA.Vector Double -- ^ X+ -> LA.Vector Double -- ^ Y+ -> Either Text FitYByXResult+fitYByX xt yt x y+ | LA.size x /= LA.size y =+ Left "fitYByX: X and Y must have the same length"+ | LA.size x < 2 =+ Left "fitYByX: need at least 2 observations"+ | otherwise = case (xt, yt) of+ (Continuous, Continuous) ->+ let xMat = LA.fromColumns [LA.fromList (replicate (LA.size x) 1), x]+ in Right (FitContCont (LM.fitLMVec xMat y))++ (Categorical, Continuous) ->+ let levels = sort (nub (LA.toList x))+ groups = [ LA.fromList+ [ LA.atIndex y i+ | i <- [0 .. LA.size x - 1]+ , LA.atIndex x i == lvl ]+ | lvl <- levels ]+ tr = ST.anovaOneWay groups+ means = [ LA.sumElements g / fromIntegral (LA.size g)+ | g <- groups ]+ in if any ((< 1) . LA.size) groups+ then Left "fitYByX (cat × cont): some groups are empty"+ else Right (FitCatCont tr means)++ (Continuous, Categorical) ->+ -- Y must be binary 0/1 for logistic+ let ys = LA.toList y+ in if not (all (\v -> v == 0 || v == 1) ys)+ then Left "fitYByX (cont × cat): Y must be binary 0/1 for logistic GLM"+ else+ let xMat = LA.fromColumns+ [LA.fromList (replicate (LA.size x) 1), x]+ in Right (FitContCat (GLM.fitGLM GLM.Binomial xMat y))++ (Categorical, Categorical) ->+ let xLevels = sort (nub (LA.toList x))+ yLevels = sort (nub (LA.toList y))+ cell xl yl = fromIntegral $ length+ [ () | i <- [0 .. LA.size x - 1]+ , LA.atIndex x i == xl+ , LA.atIndex y i == yl ]+ tbl = LA.fromLists+ [ [ cell xl yl | yl <- yLevels ] | xl <- xLevels ]+ in if length xLevels < 2 || length yLevels < 2+ then Left "fitYByX (cat × cat): need at least 2 levels per axis"+ else Right (FitCatCat (ST.chiSquareIndep tbl))+ where+ _ = V.length :: V.Vector Int -> Int -- silence warn
+ src/Hanalyze/Model/Formula.hs view
@@ -0,0 +1,272 @@+{-# LANGUAGE OverloadedStrings #-}++-- |+-- Module : Hanalyze.Model.Formula+-- Description : Formula DSL 正本 front-end (独自・明示係数構文) の parser と AST+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- [日本語]: Formula DSL — 正本 front-end (独自・明示係数構文) の parser と AST。+--+-- このモジュールの責務は「文字列 → 構文木 (Formula AST)」 のみ。+-- AST が真の正本で、 R/patsy front-end も同じ AST に落とす。+-- 意味論的分類 (Ref がデータ変数かパラメータか・factor 添字・基底展開) は+-- data と突合する後段 (ModelFrame / designMatrixF) に委ねる。+-- ゆえに本モジュールは plot 非依存・portable (upstream hanalyze cherry-pick 候補)。+--+-- 構文 (例): @"y x group = b0 + b1*x + b2*log x + bg ! group"@+-- - 左辺 @y x group@ で 応答=y / データ変数=x,group を宣言。+-- - 右辺の自由名 (左辺に無い名前) = 推定パラメータ。+-- - @+@ @-@ @*@ @/@ @^@ は常に本物の算術 (R formula の「項追加」 ではない)。+-- - 添字 @bg ! group@ = 係数ベクトル × factor 水準 (@!@ は Haskell 正規の添字演算子)。+-- - 交互作用は型で分解: 連続×連続 @b*x*z@ / factor×連続 @bg ! group * x@ /+-- factor×factor @b ! x ! z@ (@!@ 連鎖 = 2 次元添字)。+-- - 適用 @log x@ / @exp(-b*x)@ / @bspline(x,k)@ (空白並置・括弧引数どちらも App)。+--+-- [English]: Formula DSL — the parser and AST for the canonical+-- front-end (its own, explicit-coefficient syntax).+--+-- This module's sole responsibility is "string → syntax tree (Formula+-- AST)". The AST is the true source of truth, and the R/patsy+-- front-end also lowers into the same AST. Semantic classification+-- (whether a Ref is a data variable or a parameter, factor+-- subscripting, basis expansion) is deferred to the later stage that+-- cross-references it against the data (ModelFrame \/ designMatrixF).+-- Hence this module is plot-independent and portable (a candidate for+-- cherry-picking into the upstream hanalyze).+--+-- Syntax (example): @"y x group = b0 + b1*x + b2*log x + bg ! group"@+-- - The left-hand side @y x group@ declares response=y \/ data+-- variables=x,group.+-- - Free names on the right-hand side (names not on the left) are+-- estimated parameters.+-- - @+@ @-@ @*@ @/@ @^@ are always real arithmetic (not R formula's+-- "term addition").+-- - Subscript @bg ! group@ = coefficient vector × factor level (@!@+-- is Haskell's ordinary indexing operator).+-- - Interactions are decomposed by type: continuous×continuous+-- @b*x*z@ \/ factor×continuous @bg ! group * x@ \/+-- factor×factor @b ! x ! z@ (chained @!@ = 2-dimensional+-- subscripting).+-- - Application @log x@ \/ @exp(-b*x)@ \/ @bspline(x,k)@ (both+-- whitespace juxtaposition and parenthesized arguments are App).+module Hanalyze.Model.Formula+ ( -- * AST (真の正本)+ Formula (..)+ , Term (..)+ , BinOp (..)+ -- * Parse (正本 front-end = 独自構文)+ , parseFormula+ -- * Pretty (round-trip 検証用・正規形)+ , prettyFormula+ , prettyTerm+ ) where++import Control.Monad.Combinators.Expr (Operator (..), makeExprParser)+import Data.Text (Text)+import qualified Data.Text as T+import Data.Void (Void)+import Text.Megaparsec+import Text.Megaparsec.Char (alphaNumChar, char, letterChar,+ space1)+import qualified Text.Megaparsec.Char.Lexer as L++-- ============================================================================+-- AST — parse 結果の構文木 (意味論的分類は後段)+-- ============================================================================++-- | [日本語]: 二項算術演算子 (すべて本物の算術)。+-- [English]: Binary arithmetic operators (all real arithmetic).+data BinOp = Add | Sub | Mul | Div | Pow+ deriving (Eq, Show)++-- | [日本語]: 右辺の式木。 Ref がデータ変数かパラメータかは 'Formula' の LHS+-- 宣言で決まる。+-- [English]: The expression tree for the right-hand side. Whether a+-- Ref is a data variable or a parameter is decided by the LHS+-- declaration in 'Formula'.+data Term+ = Lit Double -- ^ [日本語]: 数値リテラル (非負。 負号は 'Neg' が担う)。 [English]: A numeric literal (non-negative; the sign is handled by 'Neg').+ | Ref Text -- ^ [日本語]: 識別子参照 (x / b1 / group)。 [English]: An identifier reference (x / b1 / group).+ | App Text [Term] -- ^ [日本語]: 関数適用 log x / exp(-b*x) / bspline(x,k)。 [English]: Function application: log x / exp(-b*x) / bspline(x,k).+ | Index Term Term -- ^ [日本語]: 添字 bg ! group (連鎖 b!x!z = Index (Index (Ref b) (Ref x)) (Ref z))。 [English]: Subscripting bg ! group (chained b!x!z = Index (Index (Ref b) (Ref x)) (Ref z)).+ | Neg Term -- ^ [日本語]: 単項マイナス -x。 [English]: Unary minus -x.+ | Bin BinOp Term Term -- ^ [日本語]: 二項算術。 [English]: Binary arithmetic.+ deriving (Eq, Show)++-- | [日本語]: formula 全体。 左辺で応答 + データ変数を宣言、 右辺が式。+-- [English]: The whole formula. The left-hand side declares the+-- response plus data variables, and the right-hand side is the+-- expression.+data Formula = Formula+ { formResponse :: Text -- ^ [日本語]: 応答変数 y。 [English]: The response variable y.+ , formDataVars :: [Text] -- ^ [日本語]: データ変数宣言 (x, group, …)。 右辺の自由名でこれに無い名前 = パラメータ。 [English]: Data-variable declarations (x, group, …). Free names on the right-hand side not in this list are parameters.+ , formRHS :: Term -- ^ [日本語]: 右辺式。 [English]: The right-hand-side expression.+ }+ deriving (Eq, Show)++-- ============================================================================+-- Parser (megaparsec) — 字句 / 優先順位 / formula 全体+-- ============================================================================++type Parser = Parsec Void Text++-- | [日本語]: 空白消費 (コメントは持たない)。+-- [English]: Consumes whitespace (has no comment support).+sc :: Parser ()+sc = L.space space1 empty empty++lexeme :: Parser a -> Parser a+lexeme = L.lexeme sc++symbol :: Text -> Parser Text+symbol = L.symbol sc++-- | [日本語]: 識別子: 英字/_ 始まり、 英数/_ 継続。+-- [English]: An identifier: starts with a letter or @_@, continues+-- with letters/digits/@_@.+identifier :: Parser Text+identifier = lexeme $ do+ c <- letterChar <|> char '_'+ cs <- many (alphaNumChar <|> char '_')+ pure (T.pack (c : cs))++-- | [日本語]: 数値リテラル (非負)。 float 優先 (0.5)、 無ければ整数 (2)。+-- [English]: A numeric literal (non-negative). Tries float first+-- (0.5), falling back to integer (2).+number :: Parser Double+number = lexeme (try L.float <|> (fromIntegral <$> (L.decimal :: Parser Integer)))++-- | [日本語]: 括弧でくくった部分式 (grouping)。+-- [English]: A subexpression wrapped in parentheses (grouping).+parens :: Parser a -> Parser a+parens = between (symbol "(") (symbol ")")++-- | [日本語]: atom = 数値 | 括弧グループ | 識別子参照。+-- [English]: atom = number | parenthesized group | identifier+-- reference.+pAtom :: Parser Term+pAtom =+ (Lit <$> number)+ <|> parens pExpr+ <|> (Ref <$> identifier)++-- | [日本語]: 適用項。 識別子の直後に+-- - 括弧引数 @f(a, b, …)@ が来れば多引数 App、+-- - 空白並置 atom @log x@ が来れば単/多引数 App、+-- どちらも無ければただの atom。+-- [English]: An application term. Immediately after an identifier:+-- - if parenthesized arguments @f(a, b, …)@ follow, a multi-arg App;+-- - if a whitespace-juxtaposed atom @log x@ follows, a single-\/+-- multi-arg App;+-- if neither, just the atom.+pApp :: Parser Term+pApp = do+ h <- pAtom+ case h of+ Ref f -> do+ mcall <- optional (parens (pExpr `sepBy1` symbol ","))+ case mcall of+ Just args -> pure (App f args) -- f(a, b)+ Nothing -> do+ xs <- many pAtom -- log x (空白並置)+ pure (if null xs then h else App f xs)+ _ -> pure h++-- | [日本語]: 式 (優先順位付き)。 高→低: @!@ 添字 > @^@ > 単項@-@ > @* /@ > @+ -@。+-- [English]: An expression (with precedence). Highest to lowest:+-- @!@ subscript > @^@ > unary @-@ > @* /@ > @+ -@.+pExpr :: Parser Term+pExpr = makeExprParser pApp opTable++opTable :: [[Operator Parser Term]]+opTable =+ [ [ InfixL (Index <$ symbol "!") ] -- 添字 (左結合・最高位)+ , [ InfixR (Bin Pow <$ symbol "^") ] -- べき (右結合)+ , [ Prefix (Neg <$ symbol "-") ] -- 単項マイナス (^ より下)+ , [ InfixL (Bin Mul <$ symbol "*")+ , InfixL (Bin Div <$ symbol "/") ]+ , [ InfixL (Bin Add <$ symbol "+")+ , InfixL (Bin Sub <$ symbol "-") ]+ ]++-- | [日本語]: formula 全体: @LHS変数列 = RHS式@。+-- [English]: The whole formula: @LHS variable list = RHS expression@.+pFormula :: Parser Formula+pFormula = do+ sc+ vars <- some identifier+ _ <- symbol "="+ rhs <- pExpr+ eof+ case vars of+ (y : ds) -> pure (Formula y ds rhs)+ [] -> fail "左辺に応答変数がありません"++-- | [日本語]: 文字列 → 'Formula'。 失敗時は人間可読なエラーメッセージ。+-- [English]: Parses a string into a 'Formula'. On failure, produces a+-- human-readable error message.+parseFormula :: Text -> Either String Formula+parseFormula t =+ case parse pFormula "<formula>" t of+ Left err -> Left (errorBundlePretty err)+ Right f -> Right f++-- ============================================================================+-- Pretty — round-trip の正規形 (App は常に括弧形式で曖昧性ゼロ)+-- ============================================================================++-- | [日本語]: 'Formula' を正規形文字列に。 @parseFormula (prettyFormula f) ==+-- Right f@ を満たす。+-- [English]: Renders a 'Formula' to its canonical-form string.+-- Satisfies @parseFormula (prettyFormula f) == Right f@.+prettyFormula :: Formula -> Text+prettyFormula (Formula y ds rhs) =+ T.unwords (y : ds) <> " = " <> prettyTerm rhs++-- | [日本語]: 右辺式を正規形に (優先順位に応じ最小限の括弧)。+-- [English]: Renders the right-hand-side expression to canonical form+-- (minimal parentheses according to precedence).+prettyTerm :: Term -> Text+prettyTerm = go 0+ where+ -- prec: 親文脈の結合度。 子の演算子優先度が親より緩ければ括弧。+ go :: Int -> Term -> Text+ go _ (Lit d) = prettyNum d+ go _ (Ref x) = x+ go _ (App f as) = f <> "(" <> T.intercalate ", " (map (go 0) as) <> ")"+ go p (Index a b) = paren (p > 6) (go 6 a <> " ! " <> go 7 b)+ -- operand は prec 5 で描く: 連続前置 (Neg (Neg …) = "-(-…)") も括弧化され parse 可能に。+ go p (Neg a) = paren (p > 4) ("-" <> go 5 a)+ go p (Bin op a b) =+ let pr = binPrec op+ (lp, rp) = case op of+ Pow -> (pr + 1, pr) -- 右結合+ _ -> (pr, pr + 1) -- 左結合+ in paren (p > pr) (go lp a <> " " <> binSym op <> " " <> go rp b)++ paren True s = "(" <> s <> ")"+ paren False s = s++binPrec :: BinOp -> Int+binPrec Add = 1+binPrec Sub = 1+binPrec Mul = 2+binPrec Div = 2+binPrec Pow = 5++binSym :: BinOp -> Text+binSym Add = "+"+binSym Sub = "-"+binSym Mul = "*"+binSym Div = "/"+binSym Pow = "^"++-- | [日本語]: 整数値は小数点無しで (round-trip 安定)。+-- [English]: Integer-valued numbers are rendered without a decimal+-- point (for round-trip stability).+prettyNum :: Double -> Text+prettyNum d+ | d == fromIntegral n = T.pack (show n)+ | otherwise = T.pack (show d)+ where n = round d :: Integer
+ src/Hanalyze/Model/Formula/Design.hs view
@@ -0,0 +1,559 @@+{-# LANGUAGE OverloadedStrings #-}++-- |+-- Module : Hanalyze.Model.Formula.Design+-- Description : Formula DSL の設計行列組み立て (designMatrixF) + 線形性/識別性検出+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- [日本語]: Formula DSL — designMatrixF + 線形性検出 + 識別性 (A17)。+-- 'ModelFrame' から OLS 用の設計行列を組み立て、 線形モデルなら 'fitLMF' で fit する。+--+-- ★中核の考え方:+-- - 右辺を加法項に分解し、 各項を乗法葉 (param / factor 添字 / data 式) に分類。+-- - __線形 OLS では parameter 名自体は fit に効かない__ (各設計列に 1 係数が付くだけ)。+-- param 名が効くのは ① 報告 ② 非線形検出。 → param が data 式の内側に現れたら+-- 「非線形 (OLS 不可)」 として Left を返す = 線形性検出を兼ねる。+-- - factor は __使われ方 (! 添字)__ で展開 ('ModelFrame' が既に判定済)。 識別性は+-- treatment contrast: 切片があれば参照水準 (=第1水準, 昇順先頭) を drop して満ランク化。+-- - 交互作用は専用演算子を持たず、 連続×連続=積・factor×連続=水準別列・factor×factor=+-- 添字連鎖の grid 展開、 として加法項ごとに独立に列生成。+--+-- ★検証原理 (parameterization 不変): ŷ と R² は contrast の取り方に依らない。+-- 飽和 factor×factor の ŷ = セル平均、 という Python 非依存オラクルで正しさを確認できる。+--+-- spline/poly 基底展開 (@bs ! bspline(x,k)@) は本 sub では未対応 (明示エラー)。 後続で配線。+--+-- [English]: Formula DSL — designMatrixF + linearity detection ++-- identifiability (A17).+-- Builds the OLS design matrix from a 'ModelFrame', and fits it with+-- 'fitLMF' if the model is linear.+--+-- ★Core idea:+-- - Decompose the right-hand side into additive terms, and classify+-- each term's multiplicative leaves (param \/ factor subscript \/+-- data expression).+-- - In __linear OLS the parameter name itself has no effect on the fit__+-- (each design column just gets one coefficient). The param+-- name matters only for ① reporting and ② nonlinearity detection —+-- if a param appears inside a data expression, this returns Left as+-- "nonlinear (OLS not applicable)", which doubles as linearity+-- detection.+-- - A factor is expanded according to __how it is used (! subscript)__+-- (already determined by 'ModelFrame'). Identifiability uses+-- treatment contrast: if there is an intercept, the reference level+-- (= the first level in ascending order) is dropped to obtain full+-- rank.+-- - Interactions have no dedicated operator; continuous×continuous =+-- product, factor×continuous = per-level columns, and+-- factor×factor = a grid expansion of the subscript chain, with+-- columns generated independently per additive term.+--+-- ★Validation principle (parameterization-invariant): ŷ and R² do not+-- depend on the choice of contrast. Correctness can be checked against+-- a Python-independent oracle: for a saturated factor×factor model,+-- ŷ = the cell means.+--+-- Spline\/poly basis expansion (@bs ! bspline(x,k)@) is not supported in+-- this sub (an explicit error is raised); wiring is planned for later.+module Hanalyze.Model.Formula.Design+ ( designMatrixF+ , fitLMF+ , responseVec+ , linearityCheck+ -- * Contrast coding (A2)+ , ContrastCoding (..)+ , contrastMatrix+ , parseContrast+ -- * weights / offset = WLS (A3)+ , WLSConfig (..)+ , defaultWLS+ , fitWLSF+ ) where++import Data.Maybe (catMaybes, isNothing)+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Vector as V+import qualified Numeric.LinearAlgebra as LA++import Hanalyze.DataIO.Convert (getDoubleVec)+import Hanalyze.DataIO.Preprocess (dropMissingRows)+import Hanalyze.Model.Core (FitResult)+import Hanalyze.Model.LM (fitLM)+import Hanalyze.Model.Spline (bsplineBasis, quantileKnots)+import Hanalyze.Model.Formula (BinOp (..), Formula (..), Term (..),+ prettyTerm)+import Hanalyze.Model.Formula.Frame+import qualified DataFrame.Internal.DataFrame as DX++-- ============================================================================+-- 加法 / 乗法への分解+-- ============================================================================++-- | [日本語]: 加法項に分解。 符号 (Sub/Neg) は係数に吸収され ŷ に効かないので Add 扱い。+-- [English]: Decomposes into additive terms. Sign (Sub/Neg) is absorbed+-- into the coefficient and does not affect ŷ, so it is treated as Add.+flattenAdd :: Term -> [Term]+flattenAdd (Bin Add a b) = flattenAdd a ++ flattenAdd b+flattenAdd (Bin Sub a b) = flattenAdd a ++ flattenAdd b+flattenAdd (Neg a) = flattenAdd a+flattenAdd t = [t]++-- | [日本語]: 乗法葉に分解。+-- [English]: Decomposes into multiplicative leaves.+mulLeaves :: Term -> [Term]+mulLeaves (Bin Mul a b) = mulLeaves a ++ mulLeaves b+mulLeaves (Neg a) = mulLeaves a+mulLeaves t = [t]++-- | [日本語]: Index spine: 入れ子添字を (base 項, [添字項]) に。 base が Ref でなければ+-- Nothing。+-- [English]: Index spine: unnests nested subscripts into (base term,+-- [subscript terms]). Returns Nothing if the base is not a Ref.+indexSpine :: Term -> Maybe (Term, [Term])+indexSpine (Index a b) = do (base, ixs) <- indexSpine a; pure (base, ixs ++ [b])+indexSpine t = Just (t, [])++-- ============================================================================+-- 乗法葉の分類+-- ============================================================================++data Leaf+ = LParam Text -- ^ [日本語]: パラメータ単独 (係数。 OLS 列は持たない)。 [English]: A bare parameter (a coefficient; has no OLS column).+ | LFactor [(Text, ContrastCoding)] -- ^ [日本語]: factor 添字 + contrast (1 個=主効果 / 複数=交互作用)。 [English]: Factor subscript(s) + contrast (one = main effect / multiple = interaction).+ | LBasis Text [Term] -- ^ [日本語]: 基底展開 (bs ! bspline(x,n) / bp ! poly(x,n))。 [English]: Basis expansion (bs ! bspline(x,n) / bp ! poly(x,n)).+ | LData Term -- ^ [日本語]: データ式 (連続変数・Lit・単項関数・算術)。 [English]: A data expression (continuous variable, Lit, unary function, arithmetic).++-- | [日本語]: 基底関数名 (! の右に App として現れたら factor でなく基底展開)。+-- [English]: Basis function names (if it appears as an App to the right+-- of !, it is a basis expansion rather than a factor).+basisFns :: [Text]+basisFns = ["poly", "opoly", "bspline"]++classify :: ModelFrame -> Term -> Either String Leaf+classify mf leaf =+ case indexSpine leaf of+ Just (Ref _, [App f args]) | f `elem` basisFns -> Right (LBasis f args)+ Just (Ref _, ixs@(_:_)) -> LFactor <$> mapM ixName ixs+ _ -> case leaf of+ Ref x+ | x `elem` mfParams mf -> Right (LParam x)+ -- 裸の factor = 主効果 (R 意味論 A17b: @y ~ … + g@ の @g@ が factor 列なら treatment+ -- contrast の主効果列。 @!@ 添字版 @bg!g@ と同一の LFactor に落とす)。+ | isFactor x -> Right (LFactor [(x, Treatment)])+ _ -> Right (LData leaf)+ where+ -- 添字 → (factor 名, contrast)。 @Ref g@ = 無注釈 treatment、+ -- @C(g, coding)@ = contrast 注釈、 @C(g)@ = treatment。+ ixName (Ref x)+ | isFactor x = Right (x, Treatment)+ | otherwise = Left $ "添字 '" <> T.unpack x <> "' は factor でなければなりません"+ ixName (App "C" (Ref x : rest))+ | isFactor x = (\c -> (x, c)) <$> codingOf rest+ | otherwise = Left $ "C(...) の '" <> T.unpack x <> "' は factor でなければなりません"+ ixName (App f _) = Left $ "基底 '" <> T.unpack f+ <> "' は factor 添字と混在できません (基底項は単独で)"+ ixName _ = Left "添字は変数名でなければなりません"+ codingOf [] = Right Treatment+ codingOf (Ref c : _) = parseContrast c+ codingOf _ = Left "C(g, coding) の coding は名前でなければなりません"+ isFactor x = case lookup x (mfRoles mf) of+ Just (RoleFactor _ _) -> True+ _ -> False++-- ============================================================================+-- データ式の評価 (パラメータが内側に出たら非線形)+-- ============================================================================++evalData :: ModelFrame -> Term -> Either String (V.Vector Double)+evalData mf 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+ | x `elem` mfParams mf -> Left $ "非線形: パラメータ '" <> T.unpack x+ <> "' がデータ式の内側に現れます (線形モデルでありません)"+ | otherwise -> Left $ "未知の変数 '" <> T.unpack x <> "'"+ Neg a -> V.map negate <$> evalData mf a+ App f [a]+ | Just fn <- lookup f unaryFns -> V.map fn <$> evalData mf a+ App f _ -> Left $ "未対応の関数 '" <> T.unpack f+ <> "' (A17 は log/exp/sqrt/sin/cos/tan/abs の単項のみ)"+ Bin op a b -> V.zipWith (binFn op) <$> evalData mf a <*> evalData mf b+ Index _ _ -> Left "添字項はデータ式に直接置けません (係数として扱われます)"+ where n = mfNRows mf++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 = (**)++-- ============================================================================+-- 加法項 → 設計列+-- ============================================================================++-- | [日本語]: 切片項か (data も factor も無く param のみ → 1 の列)。+-- [English]: Whether this is an intercept term (no data or factor, only a+-- param → a column of 1s).+isInterceptTerm :: ModelFrame -> Term -> Bool+isInterceptTerm mf term =+ case mapM (classify mf) (mulLeaves term) of+ Right leaves -> not (null leaves)+ && all isParam leaves+ _ -> False+ where isParam (LParam _) = True+ isParam _ = False++-- | [日本語]: 加法項 1 つの設計列群 (列ラベル, 列ベクトル)。+-- [English]: The design columns for a single additive term (column label,+-- column vector).+termColumns :: Bool -> ModelFrame -> Term -> Either String [(Text, V.Vector Double)]+termColumns hasInt mf term = do+ leaves <- mapM (classify mf) (mulLeaves term)+ let factorNames = concat [ fs | LFactor fs <- leaves ]+ dataLeaves = [ d | LData d <- leaves ]+ basisLeaves = [ (f, a) | LBasis f a <- leaves ]+ case basisLeaves of+ [(f, a)]+ | null factorNames && null dataLeaves -> basisColumns hasInt mf f a+ | otherwise -> Left "基底項は単独で記述してください (factor/データ式との積は未対応)"+ (_ : _ : _) -> Left "1 項に複数の基底は未対応"+ [] -> do+ dataVec <- case dataLeaves of+ [] -> Right (V.replicate (mfNRows mf) 1)+ ts -> foldr1 (V.zipWith (*)) <$> mapM (evalData mf) ts+ let dataLabel | null dataLeaves = Nothing+ | otherwise = Just (T.intercalate "*" (map prettyTerm dataLeaves))+ case factorNames of+ [] -> Right [ (prettyTerm term, dataVec) ]+ fs -> factorColumns hasInt mf fs dataVec dataLabel++-- | [日本語]: 基底展開列。+-- - @poly(x,n)@ = x¹..xⁿ (n 列・定数なし。 切片は b0 が担う → polyDesignMatrix と同 span)。+-- - @bspline(x,n)@ = degree-3 clamped B-spline、 knots = quantileKnots n x+-- (= fitSpline (BSpline 3) (quantileKnots n x) と同一基底)。 既定 degree=3、+-- @bspline(x,n,k)@ で degree 指定可。 B-spline 基底は partition of unity ゆえ切片と+-- 共線 → 切片併用時 (hasInt) は先頭基底列を drop して満ランク化 (R splines::bs 既定と同様)。+-- [English]: Basis-expansion columns.+-- - @poly(x,n)@ = x¹..xⁿ (n columns, no constant; the intercept is carried+-- by b0, so this has the same span as polyDesignMatrix).+-- - @bspline(x,n)@ = a degree-3 clamped B-spline with knots =+-- quantileKnots n x (the same basis as+-- fitSpline (BSpline 3) (quantileKnots n x)). Default degree=3;+-- @bspline(x,n,k)@ lets you specify the degree. Because the B-spline+-- basis is a partition of unity, it is collinear with the intercept, so+-- when an intercept is present (hasInt) the first basis column is+-- dropped to obtain full rank (as with R's splines::bs default).+basisColumns :: Bool -> ModelFrame -> Text -> [Term]+ -> Either String [(Text, V.Vector Double)]+basisColumns hasInt mf fname args = case (fname, args) of+ ("poly", [xe, Lit nd]) -> do+ xv <- evalData mf xe+ let deg = round nd :: Int+ pure [ (lbl xe ("^" <> tshow j), V.map (^ j) xv) | j <- [1 .. deg] ]+ -- opoly(x,n) = 実測値の直交多項式 (R poly 既定・raw=FALSE と同 span)。+ -- Vandermonde [1, x, …, xⁿ] を QR 直交化し、 定数列を落とした 1..n 列を返す。+ -- raw poly と違い列が相互直交 (不等間隔でも linear ⊥ quadratic) ゆえ効果検定が独立。+ -- ŷ は raw poly と同一 (span 不変・parameterization のみ差)。+ ("opoly", [xe, Lit nd]) -> do+ xv <- evalData mf xe+ let deg = round nd :: Int+ xs = V.toList xv+ vand = LA.fromLists [ [ x ^ p | p <- [0 .. deg] ] | x <- xs ]+ (q, _) = LA.qr vand+ qcols = take deg (drop 1 (LA.toColumns q)) -- 定数列を除いた orthogonal 基底 (1..deg)+ pure [ (lbl xe ("^" <> tshow j), V.fromList (LA.toList c))+ | (j, c) <- zip [1 :: Int ..] qcols ]+ ("bspline", [xe, Lit nk]) -> bspl xe (round nk) 3+ ("bspline", [xe, Lit nk, Lit kk]) -> bspl xe (round nk) (round kk)+ _ -> Left $ "基底 '" <> T.unpack fname+ <> "' の引数形が不正 (poly(x,n) / bspline(x,n) / bspline(x,n,k))"+ where+ bspl xe nKnots deg = do+ xv <- evalData mf xe+ let mat = bsplineBasis deg (quantileKnots nKnots xv) xv+ colsAll = map (V.fromList . LA.toList) (LA.toColumns mat)+ cols = if hasInt then drop 1 colsAll else colsAll+ pure [ (lbl xe ("_" <> tshow j), c) | (j, c) <- zip [(1 :: Int) ..] cols ]+ lbl xe suf = fname <> "(" <> prettyTerm xe <> ")" <> suf+ tshow = T.pack . show++-- | [日本語]: factor (1 個=主効果 / 複数=交互作用) を contrast 符号化で展開 (A2 一般化)。+-- ★各 factor の __contrast 行列 C__ (k×m) で行を符号化する。 交互作用列は factor ごとの+-- contrast 列の __Kronecker 積__ (各行で contrast 値の積) を取り、 data ベクトルを掛ける。+-- ★符号化の縮約は __指示列のとき (dataLabel == Nothing) のみ__: 指示列は合計が切片 (1s)+-- と共線ゆえ contrast 行列 (k×(k-1)) で 1 列落として満ランク化する。 一方 factor×連続+-- (dataLabel == Just、 masked データ列) は切片と共線でない → __full coding (k×k 単位行列)__+-- = 全水準保持で per-level の傾きを持つ (masked 列罠を踏襲。 落とすと参照群の+-- 傾きが 0 固定で自由度を失う = statsmodels の C(g):x と不一致)。 full coding では単位行列+-- ゆえ contrast の選択は ŷ に影響しない (= parameterization 不変)。+-- [English]: Expands a factor (one = main effect / multiple = interaction)+-- via contrast coding (A2 generalization).+-- ★Each factor's rows are coded with its __contrast matrix C__ (k×m).+-- Interaction columns are formed by taking the __Kronecker product__ of the+-- per-factor contrast columns (the product of contrast values in each row)+-- and multiplying by the data vector.+-- ★The coding is reduced __only for indicator columns__ (dataLabel ==+-- Nothing): indicator columns sum to the intercept (1s) and are+-- collinear with it, so one column is dropped via the (k×(k-1)) contrast+-- matrix to obtain full rank. Factor×continuous (dataLabel == Just, a+-- masked data column), on the other hand, is not collinear with the+-- intercept, so it uses __full coding (a k×k identity matrix)__ — keeping+-- all levels and giving each level its own slope (following the masked+-- column trap: dropping a level would fix the reference group's slope at+-- 0 and lose a degree of freedom, disagreeing with statsmodels'+-- C(g):x). With full coding, the identity matrix means the choice of+-- contrast does not affect ŷ (parameterization-invariant).+factorColumns :: Bool -> ModelFrame -> [(Text, ContrastCoding)] -> V.Vector Double -> Maybe Text+ -> Either String [(Text, V.Vector Double)]+factorColumns hasInt mf fcs dataVec dataLabel = do+ facs <- mapM getFac fcs+ let reduced = hasInt && isNothing dataLabel+ facCols = [ factorContrastCols reduced f | f <- facs ] -- factor ごとの [(列ラベル, 行ベクトル)]+ combos = cartesian facCols -- 交互作用 = 列の直積+ pure [ mkCol picks | picks <- combos ]+ where+ getFac (name, coding) = case lookup name (mfRoles mf) of+ Just (RoleFactor lev idx) -> Right (name, lev, idx, coding)+ _ -> Left $ "factor '" <> T.unpack name <> "' が ModelFrame にありません"+ mkCol picks =+ let prodVec = foldr1 (V.zipWith (*)) (map snd picks) -- 各 factor の contrast 値の積+ col = V.zipWith (*) prodVec dataVec+ lbl = T.intercalate ":" (map fst picks ++ maybe [] (: []) dataLabel)+ in (lbl, col)++-- | [日本語]: 1 factor の contrast 列群。 reduced=True で contrast 行列 (k×(k-1))、 False で+-- full coding (k×k 単位行列 = 指示変数)。 各列は行ごとの contrast 値ベクトル。+-- [English]: The contrast columns for a single factor. When reduced=True,+-- the (k×(k-1)) contrast matrix is used; when False, full coding (a k×k+-- identity matrix, i.e. indicator variables) is used. Each column is a+-- per-row contrast value vector.+factorContrastCols :: Bool -> (Text, [Text], V.Vector Int, ContrastCoding)+ -> [(Text, V.Vector Double)]+factorContrastCols reduced (nm, lev, idx, coding) =+ let k = length lev+ cmat = if reduced then contrastMatrix coding k else LA.ident k+ m = LA.cols cmat+ colVec j = V.map (\l -> cmat `LA.atIndex` (l, j)) idx+ lbl j+ | not reduced = nm <> "=" <> (lev !! j) -- full = 水準名 (指示)+ | coding == Treatment = nm <> "=" <> (lev !! (j + 1)) -- 参照 (水準0) を除く+ | otherwise = nm <> "[" <> codingTag coding <> "." <> tshow j <> "]"+ in [ (lbl j, colVec j) | j <- [0 .. m - 1] ]+ where tshow = T.pack . show++cartesian :: [[a]] -> [[a]]+cartesian [] = [[]]+cartesian (xs:rest) = [ x : r | x <- xs, r <- cartesian rest ]++-- ============================================================================+-- Contrast coding (A2)+-- ============================================================================++-- | [日本語]: factor 符号化方式。 切片併用時に満ランク化する contrast。+-- [English]: Factor coding scheme. A contrast that yields full rank when+-- used with an intercept.+data ContrastCoding+ = Treatment+ -- ^ [日本語]: 参照水準 (昇順先頭) を 0 に、 他を指示 (既定・R 既定 contr.treatment)。+ -- [English]: Sets the reference level (first in ascending order) to+ -- 0 and indicates the others (default; R's default contr.treatment).+ | Sum+ -- ^ [日本語]: sum-to-zero (最終水準 = −Σ others、 R contr.sum)。+ -- [English]: Sum-to-zero coding (last level = −Σ others; R's+ -- contr.sum).+ | Helmert+ -- ^ [日本語]: 各水準 vs それ以前の平均 (R contr.helmert)。+ -- [English]: Each level vs. the mean of the preceding levels (R's+ -- contr.helmert).+ | Polynomial+ -- ^ [日本語]: ordered factor 用の直交多項式 (R contr.poly)。+ -- [English]: Orthogonal polynomials for ordered factors (R's+ -- contr.poly).+ | CustomContrast (LA.Matrix Double)+ -- ^ [日本語]: ユーザ指定の k×(k-1) contrast 行列。+ -- [English]: A user-specified k×(k-1) contrast matrix.+ deriving (Eq, Show)++-- | [日本語]: contrast 名 (C(g, name) の name) を解釈。+-- [English]: Parses a contrast name (the name in C(g, name)).+parseContrast :: Text -> Either String ContrastCoding+parseContrast t = case T.toLower t of+ "treatment" -> Right Treatment+ "sum" -> Right Sum+ "helmert" -> Right Helmert+ "poly" -> Right Polynomial+ "polynomial" -> Right Polynomial+ _ -> Left $ "未知の contrast '" <> T.unpack t+ <> "' (Treatment/Sum/Helmert/Polynomial)"++-- | [日本語]: 列ラベル用の短いタグ。+-- [English]: A short tag for use in column labels.+codingTag :: ContrastCoding -> Text+codingTag Treatment = "T"+codingTag Sum = "S"+codingTag Helmert = "H"+codingTag Polynomial = "P"+codingTag (CustomContrast _) = "C"++-- | [日本語]: k 水準の contrast 行列 (k×(k-1))。 切片併用時の満ランク符号化。+-- 行 = 水準 (昇順 index)、 列 = contrast。 行 l の値が水準 l の設計行寄与。+-- [English]: The (k×(k-1)) contrast matrix for k levels. Full-rank coding+-- for use with an intercept. Rows = levels (ascending index), columns =+-- contrasts. Row l's values are level l's design-row contribution.+contrastMatrix :: ContrastCoding -> Int -> LA.Matrix Double+contrastMatrix coding k = case coding of+ Treatment ->+ LA.fromLists [ [ if l == j + 1 then 1 else 0 | j <- [0 .. k - 2] ] | l <- [0 .. k - 1] ]+ Sum ->+ LA.fromLists [ sumRow l | l <- [0 .. k - 1] ]+ Helmert ->+ LA.fromLists [ [ helmert l j | j <- [0 .. k - 2] ] | l <- [0 .. k - 1] ]+ Polynomial -> polyContrast k+ CustomContrast m -> m+ where+ sumRow l | l == k - 1 = replicate (k - 1) (-1)+ | otherwise = [ if l == j then 1 else 0 | j <- [0 .. k - 2] ]+ helmert l j | l <= j = -1+ | l == j + 1 = fromIntegral (j + 1)+ | otherwise = 0++-- | [日本語]: 直交多項式 contrast (k×(k-1))。 中心化水準スコアの Vandermonde を QR 分解し+-- 定数列を落とした直交基底 (R contr.poly と同 span。 符号差は ŷ 不変ゆえ無害)。+-- [English]: Orthogonal polynomial contrast (k×(k-1)). QR-decomposes the+-- Vandermonde matrix of centered level scores and drops the constant+-- column to obtain an orthogonal basis (the same span as R's contr.poly;+-- sign differences are harmless since ŷ is invariant).+polyContrast :: Int -> LA.Matrix Double+polyContrast k =+ let xs = map fromIntegral [1 .. k] :: [Double]+ xbar = sum xs / fromIntegral k+ vand = LA.fromLists [ [ (x - xbar) ^ p | p <- [0 .. k - 1] ] | x <- xs ]+ (q, _) = LA.qr vand+ in LA.fromColumns (drop 1 (LA.toColumns q))++-- ============================================================================+-- designMatrixF / fitLMF / linearityCheck+-- ============================================================================++-- | [日本語]: 'Formula' + 'ModelFrame' → 設計行列 (n×p) と列ラベル。 非線形なら Left。+-- [English]: 'Formula' + 'ModelFrame' → the (n×p) design matrix and column+-- labels. Returns Left if nonlinear.+designMatrixF :: Formula -> ModelFrame -> Either String (LA.Matrix Double, [Text])+designMatrixF (Formula _ _ rhs) mf = do+ let terms = flattenAdd rhs+ hasInt = any (isInterceptTerm mf) terms+ colss <- mapM (termColumns hasInt mf) terms+ let cols = concat colss+ labels = map fst cols+ if null cols+ then Left "空のモデル (設計列がありません)"+ else Right ( LA.fromColumns (map (LA.fromList . V.toList . snd) cols)+ , labels )++-- | [日本語]: 線形モデルを OLS で fit。 設計列ラベルも返す。 非線形なら Left。+-- [English]: Fits a linear model with OLS. Also returns the design column+-- labels. Returns Left if nonlinear.+fitLMF :: Formula -> DX.DataFrame -> Either String (FitResult, [Text])+fitLMF f df = do+ mf <- modelFrame f df+ (x, labels) <- designMatrixF f mf+ yv <- responseVec mf+ let y = LA.asColumn (LA.fromList (V.toList yv))+ Right (fitLM x y, labels)++-- | [日本語]: 応答ベクトル取り出し。+-- [English]: Extracts the response vector.+responseVec :: ModelFrame -> Either String (V.Vector Double)+responseVec mf = case mfRoles mf of+ ((_, RoleResponse v) : _) -> Right v+ _ -> Left "ModelFrame に応答列がありません"++-- ============================================================================+-- weights / offset = WLS (A3)+-- ============================================================================++-- | [日本語]: 重み付き最小二乗 + offset の設定。 statsmodels @smf.wls(formula, data, weights=…)@+-- に倣い、 weights/offset は __列名で渡す__ (R でも weights は formula 外)。+-- [English]: Configuration for weighted least squares + offset. Following+-- statsmodels' @smf.wls(formula, data, weights=…)@, weights/offset are+-- __passed by column name__ (in R too, weights are outside the formula).+data WLSConfig = WLSConfig+ { wcWeights :: Maybe Text+ -- ^ [日本語]: 重み列名 (WLS。 'Nothing' = 等重み OLS)。+ -- [English]: The weight column name (for WLS; 'Nothing' = equal-weight+ -- OLS).+ , wcOffset :: Maybe Text+ -- ^ [日本語]: offset 列名 (η への固定加算。 線形では @y* = y − offset@ を fit)。+ -- [English]: The offset column name (a fixed addition to η; for+ -- linear models this fits @y* = y − offset@).+ }+ deriving (Eq, Show)++-- | [日本語]: 既定 (重みなし・offset なし = OLS、 'fitLMF' と等価)。+-- [English]: The default (no weights, no offset = OLS; equivalent to+-- 'fitLMF').+defaultWLS :: WLSConfig+defaultWLS = WLSConfig Nothing Nothing++-- | [日本語]: weights / offset 付きで線形モデルを fit。+--+-- ★行整列: 'modelFrame' は欠損 policy で行を落とし得るので、 weights/offset 列が frame と+-- ずれないよう __formula 関与列 ∪ weights ∪ offset をまとめて 'dropMissingRows'__ してから+-- frame を組み、 weights/offset も同じ DataFrame から取り出す。+-- ★WLS = @√w@ で X/y を行スケール (@X' = diag(√w) X@, @y' = √w ⊙ y@) し OLS に帰着。+-- ★offset = η への固定加算ゆえ線形では @y − offset@ を解けばよい (GLM offset は別経路・未対応)。+-- [English]: Fits a linear model with weights \/ offset.+--+-- ★Row alignment: because 'modelFrame' can drop rows under the missing+-- policy, so the weights\/offset columns stay aligned with the frame,+-- we first call __'dropMissingRows' on the column union__ (the formula's+-- columns ∪ weights ∪ offset) before building the frame, and take+-- weights\/offset from the same DataFrame.+-- ★WLS reduces to OLS by row-scaling X\/y with @√w@ (@X' = diag(√w) X@,+-- @y' = √w ⊙ y@).+-- ★Since offset is a fixed addition to η, for linear models it suffices+-- to solve @y − offset@ (GLM offset takes a different path and is not+-- supported).+fitWLSF :: WLSConfig -> Formula -> DX.DataFrame -> Either String (FitResult, [Text])+fitWLSF cfg f@(Formula resp dvars _) df0 = do+ let extra = catMaybes [wcWeights cfg, wcOffset cfg]+ df = dropMissingRows (resp : dvars ++ extra) df0 -- 整列のため一括 drop+ mf <- modelFrame f df+ (x, labels) <- designMatrixF f mf+ yv0 <- responseVec mf+ yv <- case wcOffset cfg of+ Nothing -> Right yv0+ Just oc -> do ov <- col df oc; Right (V.zipWith (-) yv0 ov)+ case wcWeights cfg of+ Nothing -> Right (fitLM x (asCol yv), labels)+ Just wc -> do+ wv <- col df wc+ let swv = LA.fromList (map sqrt (V.toList wv)) -- √w+ xw = LA.fromColumns [ swv * c | c <- LA.toColumns x ] -- diag(√w) X+ yw = swv * LA.fromList (V.toList yv) -- √w ⊙ y+ Right (fitLM xw (LA.asColumn yw), labels)+ where+ col d name = maybe (Left $ "WLS 列 '" <> T.unpack name <> "' が数値列として見つかりません")+ Right (getDoubleVec name d)+ asCol v = LA.asColumn (LA.fromList (V.toList v))++-- | [日本語]: 線形性チェック (designMatrixF が通れば線形)。 メッセージ付き Either。+-- [English]: Checks linearity (linear if designMatrixF succeeds). An+-- Either with a message.+linearityCheck :: Formula -> DX.DataFrame -> Either String ()+linearityCheck f df = do+ mf <- modelFrame f df+ _ <- designMatrixF f mf+ Right ()
+ src/Hanalyze/Model/Formula/Frame.hs view
@@ -0,0 +1,303 @@+{-# LANGUAGE OverloadedStrings #-}++-- |+-- Module : Hanalyze.Model.Formula.Frame+-- Description : Formula DSL の ModelFrame (変数役割割り当て + パラメータ分離)+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- [日本語]: Formula DSL — ModelFrame (A16)。 'Formula' AST + @DataFrame@ を突合し、+-- 各名前に役割 (応答 / 連続データ変数 / factor) を割り当て、 推定パラメータを分離する。+--+-- ★設計の要点 (実測で確定): 「factor かどうか」 は __formula 内の使われ方__ で決まる+-- (列の型ではない)。 すなわち @bg ! group@ のように Index の右オペランドに現れた+-- データ変数を factor とみなす (numeric コードの factor も拾える)。 算術中にのみ現れる+-- データ変数は連続。 左辺で宣言されていない右辺の自由名 = 推定パラメータ。+--+-- 基底展開 (@bs ! bspline(x,k)@) の設計行列化や係数ベクトル長の確定は A17+-- (@designMatrixF@) に委ねる。 本モジュールは「役割の割り当てとパラメータ抽出」 まで。+-- DataFrame 依存ゆえ Formula.hs (純 AST) とは分離 (portable 区分は維持)。+--+-- [English]: Formula DSL — ModelFrame (A16). Matches the 'Formula' AST+-- against a @DataFrame@, assigns each name a role (response \/ continuous+-- data variable \/ factor), and separates out the parameters to be+-- estimated.+--+-- ★Key design point (confirmed empirically): whether something is a factor+-- is determined by __how it is used inside the formula__ (not by the+-- column's type). Namely, a data variable that appears as the right+-- operand of an Index, such as @bg ! group@, is treated as a factor (this+-- also catches factors coded as numerics). A data variable that appears+-- only inside arithmetic is continuous. Free names on the right-hand side+-- that are not declared on the left-hand side are the parameters to be+-- estimated.+--+-- Building the design matrix for basis expansions (@bs ! bspline(x,k)@)+-- and fixing the coefficient vector length is left to A17+-- (@designMatrixF@). This module only goes as far as "role assignment and+-- parameter extraction." Because it depends on DataFrame, it is kept+-- separate from Formula.hs (the pure AST) (the portable\/non-portable+-- split is preserved).+module Hanalyze.Model.Formula.Frame+ ( VarRole (..)+ , ModelFrame (..)+ , MissingPolicy (..)+ , ImputeKind (..)+ , modelFrame+ , modelFrameWith+ -- * 内部 (テスト用に公開)+ , refNames+ , indexedVars+ ) where++import Control.Applicative ((<|>))+import Data.List (foldl', nub, sort)+import qualified Data.Map.Strict as Map+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Vector as V+import qualified DataFrame.Internal.DataFrame as DX++import Hanalyze.DataIO.Convert (getDoubleVec, getTextVec)+import Hanalyze.DataIO.Preprocess (Value (..), countMissing, deriveText,+ dropMissingRows, imputeMean,+ imputeMedian, isNAString)+import Hanalyze.Model.Formula (Formula (..), Term (..))++-- ============================================================================+-- 役割付き列と ModelFrame+-- ============================================================================++-- | [日本語]: データ変数 (応答含む) の役割。+-- [English]: The role of a data variable (including the response).+data VarRole+ = RoleResponse (V.Vector Double) -- ^ [日本語]: 応答 y (数値)。 [English]: Response y (numeric).+ | RoleContinuous (V.Vector Double) -- ^ [日本語]: 連続説明変数 (数値)。 [English]: Continuous predictor (numeric).+ | RoleFactor [Text] (V.Vector Int) -- ^ [日本語]: factor: 水準ラベル (昇順) + 行ごとの水準 index。 [English]: Factor: level labels (ascending) plus a per-row level index.+ deriving (Eq, Show)++-- | [日本語]: AST + data を突合した結果。+-- [English]: The result of matching the AST against the data.+data ModelFrame = ModelFrame+ { mfRoles :: [(Text, VarRole)] -- ^ [日本語]: 応答 + データ変数 → 役割 (応答が先頭、 以降は宣言順)。 [English]: Response + data variables → roles (response first, then declaration order).+ , mfParams :: [Text] -- ^ [日本語]: 推定パラメータ (右辺自由名 − データ変数、 出現順)。 [English]: Parameters to estimate (right-hand-side free names minus data variables, in order of appearance).+ , mfNRows :: Int -- ^ [日本語]: 行数 (応答列の長さ)。 [English]: Row count (the response column's length).+ }+ deriving (Eq, Show)++-- | [日本語]: 欠損値の扱い方。 NA 検出・除去・補完は ModelFrame の __単一責務点__ (spec §2.2)。+-- policy で整形した DataFrame を 'buildFrame' に通すことで、 各 fit 関数に+-- NA 検出を散らさず一元化する。+-- [English]: How missing values are handled. NA detection\/removal\/imputation+-- is ModelFrame's __single point of responsibility__ (spec §2.2). Feeding+-- the DataFrame shaped by the policy into 'buildFrame' centralizes NA+-- detection instead of scattering it across each fit function.+data MissingPolicy+ = DropRows -- ^ [日本語]: NA を含む行を全関与列から除外 (listwise deletion、 既定・後方互換)。 [English]: Excludes rows containing NA across all involved columns (listwise deletion; default, for backward compatibility).+ | Pairwise -- ^ [日本語]: 線形 OLS では設計行列が成立しないので DropRows に縮退する+ -- (相関等の別用途のために policy 値としては保持。 @fitLMF@ 等は警告)。+ -- [English]: For linear OLS the design matrix cannot be formed, so this+ -- degrades to DropRows (kept as a policy value for other uses such as+ -- correlation; @fitLMF@ etc. warn).+ | Impute ImputeKind -- ^ [日本語]: 連続説明変数を平均/中央値で補完。 応答・factor の NA は+ -- 別 policy 併用が要る (Impute では埋めない)。+ -- [English]: Imputes continuous predictors with the mean\/median. NA in+ -- the response\/factors needs a combined additional policy (Impute does+ -- not fill them).+ | TreatAsCategory -- ^ [日本語]: factor 列の NA を独立水準 @"<NA>"@ として扱う。 [English]: Treats NA in factor columns as its own level, @"<NA>"@.+ | ErrorOnMissing -- ^ [日本語]: 関与列に NA があれば 'Left' (列名 + 件数つき)。 [English]: 'Left' (with column name and count) if any involved column has NA.+ deriving (Eq, Show)++-- | [日本語]: 'Impute' の補完方式。+-- [English]: The imputation method for 'Impute'.+data ImputeKind = ImputeMean | ImputeMedian+ deriving (Eq, Show)++-- ============================================================================+-- 解析ヘルパ (AST 走査)+-- ============================================================================++-- | [日本語]: 右辺に現れる全 Ref 名 (出現順、 重複あり)。+-- contrast 注釈 @C(g, Sum)@ は __factor 名 g のみ__ を拾う (coding 名 "Sum" は+-- 推定パラメータでもデータ変数でもないので除外)。+-- [English]: All Ref names appearing on the right-hand side (in order of+-- appearance, with duplicates). For a contrast annotation @C(g, Sum)@,+-- only the __factor name g__ is picked up (the coding name "Sum" is+-- excluded since it is neither a parameter to estimate nor a data+-- variable).+refNames :: Term -> [Text]+refNames t = case t of+ Ref x -> [x]+ Lit _ -> []+ App "C" (Ref x : _) -> [x] -- contrast 注釈: factor 名のみ+ App _ as -> concatMap refNames as -- 関数名 (App の Text) はパラメータでない+ Index a b -> refNames a ++ refNames b+ Neg a -> refNames a+ Bin _ a b -> refNames a ++ refNames b++-- | [日本語]: Index の右オペランドに factor として現れた名前 (= factor 候補)。+-- 右が @Ref g@ (無注釈 = treatment) または @C(g, coding)@ (contrast 注釈) なら g を拾う。+-- 右が基底展開 App (bspline / poly 等) の場合は factor でない (A17 が扱う) ので拾わない。+-- [English]: Names appearing as factors in the right operand of an Index+-- (= factor candidates). If the right side is @Ref g@ (unannotated =+-- treatment) or @C(g, coding)@ (contrast annotation), g is picked up. If+-- the right side is a basis-expansion App (bspline \/ poly etc.), it is+-- not a factor (handled by A17) so it is not picked up.+indexedVars :: Term -> [Text]+indexedVars = nub . go+ where+ go t = case t of+ Index a b -> rightRef b ++ go a ++ go b+ App _ as -> concatMap go as+ Neg a -> go a+ Bin _ a b -> go a ++ go b+ _ -> []+ rightRef (Ref x) = [x]+ rightRef (App "C" (Ref x : _)) = [x] -- C(g, coding) → factor g+ rightRef _ = []++-- ============================================================================+-- modelFrame+-- ============================================================================++-- | [日本語]: 既定 policy ('DropRows') で 'ModelFrame' を構築する (後方互換: NA 無しデータでは不変)。+-- [English]: Builds a 'ModelFrame' with the default policy ('DropRows')+-- (backward compatible: unchanged for data with no NA).+modelFrame :: Formula -> DX.DataFrame -> Either String ModelFrame+modelFrame = modelFrameWith DropRows++-- | [日本語]: 欠損 'MissingPolicy' を指定して 'ModelFrame' を構築する。+-- policy で整形した DataFrame を 'buildFrame' に通す (NA 検出・除去・補完を一元化)。+-- [English]: Builds a 'ModelFrame' with a given 'MissingPolicy' for+-- missing values. The DataFrame shaped by the policy is fed into+-- 'buildFrame' (centralizing NA detection\/removal\/imputation).+modelFrameWith :: MissingPolicy -> Formula -> DX.DataFrame -> Either String ModelFrame+modelFrameWith policy fml@(Formula resp dvars rhs) df = do+ let involved = resp : dvars+ factors = filter (`elem` dvars) (indexedVars rhs)+ conts = filter (`notElem` factors) dvars -- 連続説明変数 (factor 以外)+ naOf d c = maybe 0 id (lookup c (countMissing d)) -- 列 c の NA 件数+ df' <- case policy of+ DropRows -> Right (dropMissingRows involved df)+ Pairwise -> Right (dropMissingRows involved df) -- 単一 frame では DropRows と同義+ ErrorOnMissing ->+ let bad = [ (T.unpack c, naOf df c) | c <- involved, naOf df c > 0 ]+ in if null bad then Right df+ else Left $ "ErrorOnMissing: 欠損のある関与列 " <> show bad+ Impute kind -> do+ df1 <- imputeCols kind conts df+ let stillBad = [ T.unpack c | c <- resp : factors, naOf df1 c > 0 ]+ if null stillBad then Right df1+ else Left $ "Impute は連続説明変数のみ補完します。 応答/factor の欠損 "+ <> show stillBad <> " は DropRows か TreatAsCategory を併用してください"+ TreatAsCategory ->+ let df1 = foldl' (flip naToCategory) df factors+ stillBad = [ T.unpack c | c <- resp : conts, naOf df1 c > 0 ]+ in if null stillBad then Right df1+ else Left $ "TreatAsCategory は factor 列のみ扱います。 応答/連続の欠損 "+ <> show stillBad <> " は DropRows か Impute を併用してください"+ buildFrame fml df'++-- | [日本語]: 連続列群を平均/中央値で補完。 数値列でなければ 'Left'。+-- [English]: Imputes a set of continuous columns with the mean\/median.+-- 'Left' if a column is not numeric.+imputeCols :: ImputeKind -> [Text] -> DX.DataFrame -> Either String DX.DataFrame+imputeCols kind = go+ where+ impute1 c = case kind of { ImputeMean -> imputeMean c; ImputeMedian -> imputeMedian c }+ go [] d = Right d+ go (c:cs) d = case impute1 c d of+ Just d' -> go cs d'+ Nothing -> Left $ "連続変数 '" <> T.unpack c <> "' を数値列として補完できません"++-- | [日本語]: factor 列の NA を独立水準 @"<NA>"@ に置換した Text 列で上書きする。+-- 非 NA 値は 'showNum' で文字列化 ('columnAsText' の数値→文字列と同形)。+-- [English]: Overwrites a factor column with a Text column where NA has+-- been replaced by its own level, @"<NA>"@. Non-NA values are stringified+-- with 'showNum' (the same form as the numeric-to-string conversion in+-- 'columnAsText').+naToCategory :: Text -> DX.DataFrame -> DX.DataFrame+naToCategory c = deriveText c toLbl+ where+ toLbl row = case Map.lookup c row of+ Just (VText t) | not (isNAString t) -> t+ Just (VNum d) -> T.pack (showNum d)+ _ -> "<NA>"++-- | [日本語]: 'Formula' と (policy 適用済) @DataFrame@ を突合して 'ModelFrame' を構築する。+-- [English]: Matches a 'Formula' against a (policy-applied) @DataFrame@ to+-- build a 'ModelFrame'.+buildFrame :: Formula -> DX.DataFrame -> Either String ModelFrame+buildFrame (Formula resp dvars rhs) df = do+ -- 応答列 (数値必須)+ yv <- maybe (Left $ "応答変数 '" <> T.unpack resp <> "' が数値列として見つかりません")+ Right (getDoubleVec resp df)+ let n = V.length yv+ indexed = filter (`elem` dvars) (indexedVars rhs)+ -- R 意味論 (A17b): @!@ 添字が無くても **非数値 (Text) 列は factor** として扱う+ -- (character→factor 自動判定)。 数値列は連続のまま (numeric-coded factor は従来どおり+ -- @!@ 添字必須) なので、 従来 error だった「Text 列を裸で置いた」場合だけが factor 化する。+ autoFac = [ v | v <- dvars, v `notElem` indexed, nonNumericText v ]+ factors = indexed ++ autoFac+ params = refNames rhs `minus` (resp : dvars)+ nonNumericText v = case getDoubleVec v df of+ Just _ -> False+ Nothing -> case getTextVec v df of+ Just _ -> True+ Nothing -> False+ -- 各データ変数の役割を解決+ varRoles <- mapM (resolveVar factors df) dvars+ pure ModelFrame+ { mfRoles = (resp, RoleResponse yv) : zip dvars varRoles+ , mfParams = params+ , mfNRows = n+ }++-- | [日本語]: データ変数 1 つを役割に解決する。 factors に含まれれば factor、 さもなくば連続。+-- [English]: Resolves a single data variable to its role. Factor if it is+-- in factors, otherwise continuous.+resolveVar :: [Text] -> DX.DataFrame -> Text -> Either String VarRole+resolveVar factors df name+ | name `elem` factors = factorRole name df+ | otherwise =+ maybe (Left $ "連続変数 '" <> T.unpack name <> "' が数値列として見つかりません")+ (Right . RoleContinuous) (getDoubleVec name df)++-- | [日本語]: factor 列を水準ラベル (昇順) + 行ごとの水準 index に。+-- text 列を優先、 無ければ数値列を文字列化 (numeric コードの factor)。+-- [English]: Turns a factor column into level labels (ascending) plus a+-- per-row level index. Prefers a text column; if absent, stringifies a+-- numeric column (factors coded as numerics).+factorRole :: Text -> DX.DataFrame -> Either String VarRole+factorRole name df =+ case columnAsText name df of+ Nothing -> Left $ "factor 変数 '" <> T.unpack name <> "' が列として見つかりません"+ Just col ->+ let levels = sort (nub (V.toList col)) -- 昇順 = treatment contrast の参照=第1水準+ idxOf v = length (takeWhile (/= v) levels) -- levels 内の位置+ idx = V.map idxOf col+ in Right (RoleFactor levels idx)++-- | [日本語]: 列を [Text] 表現で取得 (factor 水準列挙用)。 text 列優先、 無ければ数値を文字列化。+-- [English]: Gets a column as a [Text] representation (for enumerating+-- factor levels). Prefers a text column; if absent, stringifies the+-- numeric values.+columnAsText :: Text -> DX.DataFrame -> Maybe (V.Vector Text)+columnAsText name df =+ getTextVec name df+ <|> (V.map (T.pack . showNum) <$> getDoubleVec name df)++-- | [日本語]: 数値を factor 水準ラベル用に文字列化 (整数は小数点なし)。+-- [English]: Stringifies a number for use as a factor level label+-- (integers have no decimal point).+showNum :: Double -> String+showNum d+ | d == fromIntegral i = show i+ | otherwise = show d+ where i = round d :: Integer++-- | [日本語]: リスト差 (左の出現順を保ち、 右に含まれる要素を除く)。+-- [English]: List difference (preserves the left list's order of+-- appearance, removing elements contained in the right list).+minus :: Eq a => [a] -> [a] -> [a]+minus xs ys = foldl' (\acc x -> if x `elem` ys || x `elem` acc then acc else acc ++ [x]) [] xs
+ src/Hanalyze/Model/Formula/Mixed.hs view
@@ -0,0 +1,292 @@+-- |+-- Module : Hanalyze.Model.Formula.Mixed+-- Description : Formula DSL の混合効果モデル (random effect) 接続層+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- [日本語]: Formula DSL — 混合効果モデル (random effect) の接続層。+--+-- lme4 流の @(1|g)@ / @(x|g)@ / @(1+x|g)@ を Formula DSL に追加し、+-- 'Hanalyze.Model.GLMM' の一般ランダム効果フィット ('fitLMEGeneral' /+-- 'fitGLMMGeneral') へ route する。+--+-- ★設計判断: random 項を AST の @Term@ 構成子として持たせず、+-- __字句プリパスで @(…|g)@ ブロックを抽出__ する方式を採る。 理由は+-- @Term@ に構成子を足すと 'Hanalyze.Model.Formula' 系 5 モジュールの網羅+-- pattern match が全て破壊されるため (計画のリスク注記)。 本方式なら+-- @Term@/'Formula' は不変で、 固定効果は既存の 'parseModel'/'designMatrixF'+-- 経路をそのまま使え、 random 項の解釈は本モジュールに閉じる。+--+-- frequentist GLMM ゆえ random 効果に prior 宣言は不要 (分散 G は推定対象)。+--+-- [English]: Formula DSL — connection layer for mixed-effects models+-- (random effects).+--+-- Adds lme4-style @(1|g)@ \/ @(x|g)@ \/ @(1+x|g)@ to the Formula DSL and+-- routes to 'Hanalyze.Model.GLMM''s general random-effects fitters+-- ('fitLMEGeneral' \/ 'fitGLMMGeneral').+--+-- ★__Design decision__: rather than giving random terms their own+-- @Term@ constructor, this module __extracts @(…|g)@ blocks in a lexical pre-pass__.+-- The reason: adding a constructor to @Term@ would break the+-- exhaustive pattern matches across the 5 modules of the+-- 'Hanalyze.Model.Formula' family (noted as a risk in the plan). This+-- approach leaves @Term@\/'Formula' unchanged — fixed effects still go+-- through the existing 'parseModel'\/'designMatrixF' path, and random-term+-- interpretation stays contained in this module.+--+-- Since this is frequentist GLMM, random effects need no prior declaration+-- (the variance G is estimated).+module Hanalyze.Model.Formula.Mixed+ ( RandomSpec (..)+ , extractRandom+ , fitMixedF+ , fitMixedLME+ , fitMixedGLMM+ ) where++import Control.Monad (unless, when)+import Data.Char (isSpace)+import Data.List (intercalate)+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Vector as V+import qualified Numeric.LinearAlgebra as LA++import qualified DataFrame.Internal.DataFrame as DXD+import Hanalyze.DataIO.Convert (getDoubleVec, getTextVec)+import Hanalyze.DataIO.Preprocess (dropMissingRows)+import Hanalyze.Model.Formula (Formula (..))+import Hanalyze.Model.Formula.Design (designMatrixF, responseVec)+import Hanalyze.Model.Formula.Frame (modelFrame)+import Hanalyze.Model.Formula.RFormula (parseModel)+import Hanalyze.Model.GLM (Family (..), LinkFn (..))+import Hanalyze.Model.GLMM (GLMMResultRE, buildGroups,+ fitGLMMGeneral, fitLMEGeneral)++-- ============================================================================+-- random 項の表現+-- ============================================================================++-- | [日本語]: 1 つの @(…|g)@ ブロックの解釈結果。+-- 例: @(1+x|g)@ → @RandomSpec True ["x"] "g"@ / @(0+x|g)@ → @RandomSpec False ["x"] "g"@.+-- [English]: The parsed result of a single @(…|g)@ block.+-- Example: @(1+x|g)@ → @RandomSpec True ["x"] "g"@ \/ @(0+x|g)@ → @RandomSpec False ["x"] "g"@.+data RandomSpec = RandomSpec+ { rsIntercept :: Bool -- ^ [日本語]: random intercept を含むか (@1@ あり or 既定 True、 @0@/@-1@ で抑制)。 [English]: Whether a random intercept is included (present via @1@ or default True; suppressed by @0@\/@-1@).+ , rsSlopes :: [Text] -- ^ [日本語]: random slope の変数名 (左辺の @1@/@0@/@-1@ 以外)。 [English]: Random-slope variable names (excluding @1@\/@0@\/@-1@ on the left-hand side).+ , rsGroup :: Text -- ^ [日本語]: grouping 変数名 (@|@ の右)。 [English]: Grouping variable name (right of @|@).+ } deriving (Eq, Show)++-- ============================================================================+-- 字句プリパス: (…|g) ブロックの抽出+-- ============================================================================++-- | [日本語]: formula 文字列から random 項 @(…|g)@ を抽出し、 (固定効果 formula, [RandomSpec])+-- を返す。 LHS (@~@ or @=@) は保持し、 RHS から random ブロックを取り除く。+--+-- - R 構文: @"y ~ x + (1+x|g)"@ → (@"y ~ x"@, [RandomSpec True ["x"] "g"])+-- - 独自構文: @"y x = b0 + b1*x + (1|g)"@ → (@"y x = b0 + b1*x"@, [RandomSpec True [] "g"])+--+-- 固定効果側に項が残らない場合 (例 @"y ~ (1|g)"@) は intercept @"1"@ を補う。+-- [English]: Extracts random terms @(…|g)@ from a formula string and+-- returns (fixed-effects formula, [RandomSpec]). The LHS (@~@ or @=@) is+-- kept as-is; random blocks are stripped from the RHS.+--+-- - R syntax: @"y ~ x + (1+x|g)"@ → (@"y ~ x"@, [RandomSpec True ["x"] "g"])+-- - Custom syntax: @"y x = b0 + b1*x + (1|g)"@ → (@"y x = b0 + b1*x"@, [RandomSpec True [] "g"])+--+-- If no terms remain on the fixed-effects side (e.g. @"y ~ (1|g)"@), an+-- intercept @"1"@ is added.+extractRandom :: Text -> Either String (Text, [RandomSpec])+extractRandom t =+ let s = T.unpack t+ (lhs, sep, rhs) = splitLHS s+ in do+ tokens <- pure (splitTopPlus rhs)+ (fixedToks, specStrs) <- partitionTokens tokens+ specs <- mapM parseBlock specStrs+ let fixedRHS = case map trimStr (filter (not . all isSpace) fixedToks) of+ [] -> "1"+ ts -> intercalate " + " ts+ fixedFormula = case sep of+ "" -> fixedRHS -- LHS 無し (RHS のみ)+ _ -> trimStr lhs ++ " " ++ sep ++ " " ++ fixedRHS+ Right (T.pack fixedFormula, specs)++-- | [日本語]: LHS と RHS を @~@ (R) または @=@ (独自) で分割。 区切りが無ければ ("", "", whole)。+-- [English]: Splits into LHS and RHS on @~@ (R) or @=@ (custom). If no+-- separator is found, returns ("", "", whole).+splitLHS :: String -> (String, String, String)+splitLHS s+ | Just (l, r) <- breakTop '~' s = (l, "~", r)+ | Just (l, r) <- breakTop '=' s = (l, "=", r)+ | otherwise = ("", "", s)++-- | [日本語]: top-level (括弧外) の最初の区切り文字で 1 回分割。+-- [English]: Splits once at the first top-level (outside parentheses)+-- separator character.+breakTop :: Char -> String -> Maybe (String, String)+breakTop target = go (0 :: Int) []+ where+ go _ _ [] = Nothing+ go d acc (c:cs)+ | c == '(' = go (d+1) (c:acc) cs+ | c == ')' = go (d-1) (c:acc) cs+ | c == target && d == 0 = Just (reverse acc, cs)+ | otherwise = go d (c:acc) cs++-- | [日本語]: top-level の @+@ で分割 (括弧内の @+@ は分割しない)。+-- [English]: Splits on top-level @+@ (does not split on @+@ inside+-- parentheses).+splitTopPlus :: String -> [String]+splitTopPlus = go (0 :: Int) [] []+ where+ go _ cur acc [] = reverse (reverse cur : acc)+ go d cur acc (c:cs)+ | c == '(' = go (d+1) (c:cur) acc cs+ | c == ')' = go (d-1) (c:cur) acc cs+ | c == '+' && d == 0 = go d [] (reverse cur : acc) cs+ | otherwise = go d (c:cur) acc cs++-- | [日本語]: 各トークンを固定効果トークンか random ブロック (中身) に振り分ける。+-- random ブロック = trim 後 @(…)@ で囲まれ、 内部 top-level に @|@ を持つもの。+-- [English]: Sorts each token into either a fixed-effects token or a+-- random block (its contents). A random block is one that, after+-- trimming, is enclosed in @(…)@ and contains a top-level @|@ inside.+partitionTokens :: [String] -> Either String ([String], [String])+partitionTokens = go [] []+ where+ go fixed rand [] = Right (reverse fixed, reverse rand)+ go fixed rand (tok:rest) =+ case asRandomBlock (trimStr tok) of+ Just inner -> go fixed (inner : rand) rest+ Nothing -> go (tok : fixed) rand rest++-- | [日本語]: トークンが @(…|…)@ なら内部文字列を返す。+-- [English]: If the token is @(…|…)@, returns the inner string.+asRandomBlock :: String -> Maybe String+asRandomBlock tok =+ case tok of+ ('(':rest) | not (null rest), last rest == ')' ->+ let inner = init rest+ in if hasTopPipe inner then Just inner else Nothing+ _ -> Nothing++-- | [日本語]: top-level に @|@ を含むか。+-- [English]: Whether it contains a top-level @|@.+hasTopPipe :: String -> Bool+hasTopPipe = go (0 :: Int)+ where+ go _ [] = False+ go d (c:cs)+ | c == '(' = go (d+1) cs+ | c == ')' = go (d-1) cs+ | c == '|' && d == 0 = True+ | otherwise = go d cs++-- | [日本語]: @"1 + x | g"@ → 'RandomSpec'。+-- [English]: @"1 + x | g"@ → 'RandomSpec'.+parseBlock :: String -> Either String RandomSpec+parseBlock inner =+ case breakTop '|' inner of+ Nothing -> Left "random ブロックに '|' がありません"+ Just (lhs, rhs) ->+ let grp = trimStr rhs+ terms = map trimStr (splitTopPlus lhs)+ isSup t = t == "0" || t == "-1"+ isOne t = t == "1"+ hasSup = any isSup terms+ slopes = [ T.pack t | t <- terms, not (isSup t), not (isOne t), not (null t) ]+ in if null grp+ then Left "random ブロックの grouping 変数 (| の右) が空です"+ else Right RandomSpec+ { rsIntercept = not hasSup -- 0/-1 が無ければ intercept あり+ , rsSlopes = slopes+ , rsGroup = T.pack grp+ }++trimStr :: String -> String+trimStr = f . f where f = reverse . dropWhile isSpace++-- ============================================================================+-- route 入口: 固定/random を分離し GLMM 一般フィットへ+-- ============================================================================++-- | [日本語]: 混合効果モデルを DataFrame からフィットする。 @Nothing@ = Gaussian LME+-- ('fitLMEGeneral')、 @Just (family, link)@ = 非 Gaussian GLMM ('fitGLMMGeneral')。+-- 戻り値は (結果, 固定効果係数名)。+--+-- ★現状は __単一 grouping factor__ のみ対応 ((1|g) / (x|g) / (1+x|g))。 複数の+-- @(…|g1) + (…|g2)@ は block-diagonal Z が要るため未対応 (明示エラー)。+--+-- TODO (follow-up):+-- - 複数 grouping factor @(…|g1) + (…|g2)@ — 群ごと Z ブロックを block-diagonal に+-- 積み、 fitLMEGeneral/fitGLMMGeneral を multi-grouping 一般化する。+-- - GLMM offset (Poisson log-exposure 等) — 現状は線形 offset のみ (@fitWLSF@)。+-- - REML 推定 — 現状の EM/Laplace は ML。 REML は固定効果 df 補正付き。+-- [English]: Fits a mixed-effects model from a DataFrame. @Nothing@ =+-- Gaussian LME ('fitLMEGeneral'), @Just (family, link)@ = non-Gaussian GLMM+-- ('fitGLMMGeneral'). Returns (result, fixed-effect coefficient names).+--+-- ★Currently supports only a __single grouping factor__ ((1|g) \/ (x|g) \/+-- (1+x|g)). Multiple @(…|g1) + (…|g2)@ is not supported yet, since it+-- would require a block-diagonal Z (raises an explicit error).+--+-- TODO (follow-up):+-- - Multiple grouping factors @(…|g1) + (…|g2)@ — stack per-group Z+-- blocks block-diagonally and generalize fitLMEGeneral\/fitGLMMGeneral+-- to multi-grouping.+-- - GLMM offset (e.g. Poisson log-exposure) — currently only linear+-- offset is supported (@fitWLSF@).+-- - REML estimation — the current EM\/Laplace is ML. REML would add a+-- fixed-effect df correction.+fitMixedF+ :: Maybe (Family, LinkFn)+ -> Text -> DXD.DataFrame+ -> Either String (GLMMResultRE, [Text])+fitMixedF mfam formulaText df0 = do+ (fixedText, specs) <- extractRandom formulaText+ spec <- case specs of+ [s] -> Right s+ [] -> Left "random effect 項 (…|g) がありません (固定効果のみなら fitLMF を使用)"+ _ -> Left "複数の grouping factor は未対応 (単一の (…|g) のみ)"+ f@(Formula resp dvars _) <- parseModel fixedText+ let slopeVars = rsSlopes spec+ grp = rsGroup spec+ -- 行整列: fixedWLF と同じく formula 関与列 ∪ slope ∪ group を一括 drop+ df = dropMissingRows (resp : dvars ++ slopeVars ++ [grp]) df0+ mf <- modelFrame f df+ (x, labels) <- designMatrixF f mf+ yv <- responseVec mf+ let n = V.length yv+ slopeCols <- mapM (\v ->+ maybe (Left $ "random slope 列 '" <> T.unpack v <> "' が数値列として見つかりません")+ Right (getDoubleVec v df)) slopeVars+ let interceptCol = [ V.replicate n 1.0 | rsIntercept spec ]+ zCols = interceptCol ++ slopeCols+ when (null zCols) $ Left "random effect の設計列が空です ((0|g) のみは不可)"+ unless (all ((== n) . V.length) zCols) $+ Left "random slope 列の長さが応答と一致しません"+ gv <- maybe (Left $ "grouping 列 '" <> T.unpack grp <> "' が見つかりません")+ Right (getTextVec grp df)+ let z = LA.fromColumns (map (LA.fromList . V.toList) zCols)+ y = LA.fromList (V.toList yv)+ (glabels, idx, _sizes) = buildGroups gv+ res = case mfam of+ Nothing -> fitLMEGeneral x z y idx glabels+ Just (fam, link) -> fitGLMMGeneral fam link x z y idx glabels+ Right (res, labels)++-- | [日本語]: Gaussian 線形混合効果モデル (LME)。 @fitMixedLME "y ~ x + (1+x|g)" df@。+-- [English]: Gaussian linear mixed-effects model (LME).+-- @fitMixedLME "y ~ x + (1+x|g)" df@.+fitMixedLME :: Text -> DXD.DataFrame -> Either String (GLMMResultRE, [Text])+fitMixedLME = fitMixedF Nothing++-- | [日本語]: 非 Gaussian GLMM。 @fitMixedGLMM Binomial Logit "y ~ x + (1|g)" df@。+-- [English]: Non-Gaussian GLMM.+-- @fitMixedGLMM Binomial Logit "y ~ x + (1|g)" df@.+fitMixedGLMM :: Family -> LinkFn -> Text -> DXD.DataFrame+ -> Either String (GLMMResultRE, [Text])+fitMixedGLMM fam link = fitMixedF (Just (fam, link))
+ src/Hanalyze/Model/Formula/Nonlinear.hs view
@@ -0,0 +1,140 @@+{-# 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)。+-- 現状 @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。+--+-- [English]: Formula DSL — nonlinear least squares (NLS).+-- Fits expressions like @a*exp(-b*x)@, where+-- __parameters appear inside the data expression itself__ (so+-- @designMatrixF@ considers them non-linear and returns 'Left'), by+-- turning the parsed AST into an evaluation function and minimizing+-- SSR with the existing optimizer ('Hanalyze.Optim.NelderMead').+--+-- ★ Concept: unlike linear OLS, parameter names affect ŷ. @evalNL@+-- evaluates the right-hand-side expression into a __per-row ŷ vector__+-- from "the params table + ModelFrame" (params are constants,+-- continuous data variables are column vectors). The objective+-- @SSR(θ) = Σ(y − ŷ(θ))²@ is minimized with Nelder-Mead.+-- ★ Initial values are required from the user (NLS depends on them).+-- Factor indexing is not supported (handled on the linear side).+-- ★ The optimizer returns IO but is deterministic, so it is purified+-- with 'unsafePerformIO' (same policy as Convert.hs).+--+-- Independent of 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 の結果。+-- [English]: The result of a nonlinear fit.+data NLSResult = NLSResult+ { nlsParams :: [(Text, Double)] -- ^ [日本語]: 推定パラメータ (名前つき) [English]: Estimated parameters (named)+ , nlsFitted :: V.Vector Double -- ^ [日本語]: ŷ [English]: ŷ+ , nlsResidual :: V.Vector Double -- ^ [日本語]: y − ŷ [English]: y − ŷ+ , nlsSSR :: Double -- ^ [日本語]: 残差平方和 [English]: Residual sum of squares+ , nlsConverged :: Bool -- ^ [日本語]: 最適化器が許容誤差で停止したか [English]: Whether the optimizer stopped within tolerance+ }+ deriving (Eq, Show)++-- | [日本語]: 右辺式を __行ごとの値ベクトル__ に評価する。 params は表から定数、 連続データ変数は+-- ModelFrame の列、 factor / 応答は 'Left'。 (線形の @evalData@ と違い param を許す。)+-- [English]: Evaluates the right-hand-side expression into a+-- __per-row value vector__. Params are constants from the table,+-- continuous data variables are ModelFrame columns, and factor \/+-- response give 'Left'.+-- (Unlike the linear @evalData@, this allows params.)+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) を出すパラメータ域は +∞ で罰する。+-- [English]: Nonlinear least squares. @inits@ is the initial value for+-- each parameter (must cover all of mfParams). Minimizes SSR with+-- Nelder-Mead. Parameter regions producing invalid values (NaN) are+-- penalized with +∞.+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+ }
+ src/Hanalyze/Model/Formula/RFormula.hs view
@@ -0,0 +1,324 @@+{-# LANGUAGE OverloadedStrings #-}++-- |+-- Module : Hanalyze.Model.Formula.RFormula+-- Description : Formula DSL の R/patsy 互換 front-end (@y ~ x + C(g)@ 構文)+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- [日本語]: Formula DSL — R/patsy front-end (A18)。 @y ~ x + C(g)@ 形式を+-- __同じ 'Formula' AST__ に落とす (サブ front-end)。 正本は独自構文 (A15)、+-- 本モジュールは互換・オラクル用途。+--+-- ★dispatch: 文字列に @~@ が含まれれば R、 無ければ独自 ('parseModel')。 @~@ と @=@ は+-- 字句的に分離ゆえ曖昧性ゼロ。+--+-- ★R formula 意味論 → 我々の AST:+-- - @~@ で 応答 / 予測子 を分離。 予測子は @+@ 区切り (これは「項追加」、 算術でない)。+-- - 暗黙の切片あり。 @-1@ / @0@ で切片除去。+-- - 連続変数 @x@ → @b*x@ (本物の積)。 ★categorical は __@C(g)@__ で明示+-- (patsy 同様。 data 無しで parse するため列型推論はしない)。+-- - @a:b@ = 交互作用のみ、 @a*b@ = @a + b + a:b@ (crossing)。+-- - @I(expr)@ = 算術 (@x**2@/@x^2@ 等)、 @log(x)@ = 関数変換、 @poly(x,n)@/@bs(x,n)@ = 基底。+-- ★パラメータ名は合成 (@_p0,_p1,…@)。 線形 OLS では係数名は fit に無関係ゆえ問題なし。+-- ★data 変数は RHS に現れた変数名 (合成パラメータ以外) を収集。+--+-- plot 非依存・portable (AST のみ依存)。+--+-- [English]: Formula DSL — R\/patsy front-end (A18). Compiles the+-- @y ~ x + C(g)@ form down to the __same 'Formula' AST__ (a sub+-- front-end). The canonical syntax is the original one (A15); this module+-- is for compatibility\/oracle use.+--+-- ★Dispatch: if the string contains @~@, use R; otherwise use the+-- original ('parseModel'). @~@ and @=@ are lexically distinct, so there is+-- zero ambiguity.+--+-- ★R formula semantics → our AST:+-- - @~@ separates the response \/ predictors. Predictors are @+@+-- separated (this is "term addition," not arithmetic).+-- - There is an implicit intercept. @-1@ \/ @0@ removes the intercept.+-- - A continuous variable @x@ → @b*x@ (a genuine product). ★categorical+-- is made explicit with __@C(g)@__ (as in patsy; since parsing happens+-- without data, there is no column-type inference).+-- - @a:b@ = interaction only, @a*b@ = @a + b + a:b@ (crossing).+-- - @I(expr)@ = arithmetic (@x**2@\/@x^2@ etc.), @log(x)@ = function+-- transform, @poly(x,n)@\/@bs(x,n)@ = basis.+-- ★Parameter names are synthesized (@_p0,_p1,…@). For linear OLS the+-- coefficient names are irrelevant to the fit, so this is not a problem.+-- ★Data variables are collected from the variable names appearing on the+-- RHS (excluding synthesized parameters).+--+-- Plot-independent, portable (depends only on the AST).+module Hanalyze.Model.Formula.RFormula+ ( parseRFormula+ , parseModel+ ) where++import Control.Monad.Combinators.Expr (Operator (..), makeExprParser)+import Data.List (isPrefixOf, nub, subsequences)+import Data.Text (Text)+import qualified Data.Text as T+import Data.Void (Void)+import Text.Megaparsec+import Text.Megaparsec.Char (alphaNumChar, char, letterChar,+ space1)+import qualified Text.Megaparsec.Char.Lexer as L++import Hanalyze.Model.Formula (BinOp (..), Formula (..),+ Term (..), parseFormula)++-- ============================================================================+-- dispatch+-- ============================================================================++-- | [日本語]: front-end 自動判別: @~@ を含めば R、 さもなくば独自構文。+-- [English]: Automatic front-end detection: R if it contains @~@,+-- otherwise the original syntax.+parseModel :: Text -> Either String Formula+parseModel t+ | T.any (== '~') t = parseRFormula t+ | otherwise = parseFormula t++-- ============================================================================+-- 字句+-- ============================================================================++type P = Parsec Void Text++sc :: P ()+sc = L.space space1 empty empty++lexeme :: P a -> P a+lexeme = L.lexeme sc++symbol :: Text -> P Text+symbol = L.symbol sc++ident :: P Text+ident = lexeme $ do+ c <- letterChar <|> char '_'+ cs <- many (alphaNumChar <|> char '_' <|> char '.')+ pure (T.pack (c : cs))++intLit :: P Int+intLit = lexeme (L.signed (pure ()) L.decimal)++numLit :: P Double+numLit = lexeme (try (L.signed (pure ()) L.float)+ <|> (fromIntegral <$> L.signed (pure ()) (L.decimal :: P Integer)))++parens :: P a -> P a+parens = between (symbol "(") (symbol ")")++-- ============================================================================+-- 中間表現 (R 項)+-- ============================================================================++-- | [日本語]: R 項の因子。+-- [English]: A factor within an R term.+data RFactor+ = RVar Text -- ^ [日本語]: 連続変数 x。 [English]: Continuous variable x.+ | RCat Text (Maybe Text) -- ^ [日本語]: C(g) / C(g, Sum) categorical (+ contrast 名)。 [English]: C(g) \/ C(g, Sum) categorical (with an optional contrast name).+ | RFun Text Term -- ^ [日本語]: log(x) 等の関数変換 (1 引数)。 [English]: A function transform such as log(x) (1 argument).+ | RI Term -- ^ [日本語]: I(expr) 算術。 [English]: I(expr) arithmetic.+ | RPoly Text Int -- ^ [日本語]: poly(x, n) 生べき (x¹..xⁿ)。 [English]: poly(x, n), raw powers (x¹..xⁿ).+ | ROPoly Text Int -- ^ [日本語]: opoly(x, n) 実測値の直交多項式 (R poly 既定と同じ)。 [English]: opoly(x, n), orthogonal polynomials on the observed values (same as R's poly default).+ | RBs Text Int -- ^ [日本語]: bs(x, n)。 [English]: bs(x, n).++-- | [日本語]: R 項: 数値 (0/1) か、 因子の積 (hasStar=True なら crossing 展開)。+-- [English]: An R term: either a number (0\/1) or a product of factors+-- (crossing expansion when hasStar=True).+data RComp = RNum Int | RProd Bool [RFactor]++-- ============================================================================+-- パーサ+-- ============================================================================++-- | [日本語]: @lhs ~ rhs@。+-- [English]: @lhs ~ rhs@.+pRFormula :: P Formula+pRFormula = do+ sc+ lhs <- ident+ _ <- symbol "~"+ comps <- pRHS+ eof+ buildFormula lhs comps++-- | [日本語]: RHS = 符号付き項の並び。 戻り値 = (符号, 項)。+-- [English]: RHS = a sequence of signed terms. Return value = (sign, term).+pRHS :: P [(Int, RComp)]+pRHS = do+ s0 <- option 1 sign+ c0 <- pComp+ rest <- many ((,) <$> sign <*> pComp)+ pure ((s0, c0) : rest)+ where sign = (1 <$ symbol "+") <|> ((-1) <$ symbol "-")++-- | [日本語]: 1 項 (数値 or 因子の積)。+-- [English]: A single term (a number or a product of factors).+pComp :: P RComp+pComp =+ try (RNum <$> lexeme L.decimal)+ <|> pProduct++-- | [日本語]: 因子を @*@ / @:@ で結んだ積。 @*@ が 1 つでもあれば crossing。+-- [English]: A product of factors joined by @*@ \/ @:@. Crossing if there+-- is at least one @*@.+pProduct :: P RComp+pProduct = do+ f0 <- pFactor+ rest <- many ((,) <$> ((True <$ symbol "*") <|> (False <$ symbol ":")) <*> pFactor)+ let hasStar = any fst rest+ facs = f0 : map snd rest+ pure (RProd hasStar facs)++pFactor :: P RFactor+pFactor =+ try (symbol "C" *> parens pCatArgs)+ <|> try (RI <$> (symbol "I" *> parens pArith))+ <|> try (ROPoly <$> (symbol "opoly" *> symbol "(" *> ident) <*> (symbol "," *> intLit <* symbol ")"))+ <|> try (RPoly <$> (symbol "poly" *> symbol "(" *> ident) <*> (symbol "," *> intLit <* symbol ")"))+ <|> try (RBs <$> (symbol "bs" *> symbol "(" *> ident) <*> (symbol "," *> intLit <* symbol ")"))+ <|> try pFunOrVar++-- | [日本語]: @C(g)@ / @C(g, Sum)@ の中身: factor 名 + 省略可能な contrast 名。+-- [English]: The contents of @C(g)@ \/ @C(g, Sum)@: a factor name plus an+-- optional contrast name.+pCatArgs :: P RFactor+pCatArgs = do+ g <- ident+ mcode <- optional (symbol "," *> ident)+ pure (RCat g mcode)++-- | [日本語]: @log(x)@ のような関数変換、 または裸の変数。+-- [English]: A function transform like @log(x)@, or a bare variable.+pFunOrVar :: P RFactor+pFunOrVar = do+ nm <- ident+ margs <- optional (parens pArith)+ pure $ case margs of+ Just a -> RFun nm a+ Nothing -> RVar nm++-- | [日本語]: I(...) 内の算術式 (@+ - * / ^ **@・関数適用・括弧)。+-- [English]: The arithmetic expression inside I(...) (@+ - * / ^ **@,+-- function application, parentheses).+pArith :: P Term+pArith = makeExprParser pArithApp+ [ [ InfixR (Bin Pow <$ (symbol "**" <|> symbol "^")) ]+ , [ Prefix (Neg <$ symbol "-") ]+ , [ InfixL (Bin Mul <$ symbol "*"), InfixL (Bin Div <$ symbol "/") ]+ , [ InfixL (Bin Add <$ symbol "+"), InfixL (Bin Sub <$ symbol "-") ]+ ]++pArithApp :: P Term+pArithApp = do+ h <- pArithAtom+ case h of+ Ref f -> do+ margs <- optional (parens (pArith `sepBy1` symbol ","))+ pure $ maybe h (App f) margs+ _ -> pure h++pArithAtom :: P Term+pArithAtom =+ (Lit <$> numLit)+ <|> parens pArith+ <|> (Ref <$> ident)++-- ============================================================================+-- 構築 (中間表現 → Formula AST)+-- ============================================================================++buildFormula :: Text -> [(Int, RComp)] -> P Formula+buildFormula lhs comps = do+ let removeInt = any (\(s, c) -> case c of+ RNum 0 -> s == 1 -- + 0+ RNum 1 -> s == (-1) -- - 1+ _ -> False) comps+ prods = [ p | (_, RProd star fs) <- comps, p <- expand star fs ]+ terms = (if removeInt then [] else [const1]) ++ map prodToTerm prods+ if null terms+ then fail "R formula: 項がありません"+ else do+ let named = zipWith (\i mk -> mk (synth i)) [0 :: Int ..] terms+ rhs = foldr1 (Bin Add) named+ dvars = nub (filter (not . isSynth) (refNamesT rhs))+ pure (Formula lhs dvars rhs)+ where+ synth i = T.pack ("_p" ++ show i)+ const1 p = Ref p -- 切片 (定数項)++-- | [日本語]: crossing 展開: @*@ なら全非空部分集合 (R の a*b = a + b + a:b)、 @:@ なら+-- 単一交互作用。 列の順序は fit (ŷ) に無関係ゆえ 'subsequences' の順序で可。+-- [English]: Crossing expansion: for @*@, all non-empty subsets (R's+-- a*b = a + b + a:b); for @:@, a single interaction. Since column order is+-- irrelevant to the fit (ŷ), the order from 'subsequences' is fine as-is.+expand :: Bool -> [RFactor] -> [[RFactor]]+expand False fs = [fs]+expand True fs = filter (not . null) (subsequences fs)++-- | [日本語]: 1 つの積 (因子リスト) → パラメータ名を取って Term を作る関数。+-- [English]: A single product (a list of factors) → a function that takes+-- a parameter name and produces a Term.+prodToTerm :: [RFactor] -> (Text -> Term)+prodToTerm facs p =+ let cats = [ (nm, mc) | RCat nm mc <- facs ]+ polys = [ (nm, n) | RPoly nm n <- facs ]+ opolys = [ (nm, n) | ROPoly nm n <- facs ]+ bss = [ (nm, n) | RBs nm n <- facs ]+ datums = concatMap factorData facs+ in case (polys, opolys, bss) of+ ((nm, n) : _, _, _) -> Index (Ref p) (App "poly" [Ref nm, Lit (fromIntegral n)])+ (_, (nm, n) : _, _) -> Index (Ref p) (App "opoly" [Ref nm, Lit (fromIntegral n)])+ (_, _, (nm, n) : _) -> Index (Ref p) (App "bspline" [Ref nm, Lit (fromIntegral n)])+ _ ->+ let base = foldl (\acc (nm, mc) -> Index acc (catTerm nm mc)) (Ref p) cats+ in case datums of+ [] -> base -- 切片 or 純 factor+ (d:ds) -> Bin Mul base (foldl (Bin Mul) d ds)++-- | [日本語]: categorical 添字項を AST に: @C(g)@ → @Ref g@ (無注釈 treatment)、+-- @C(g, Sum)@ → @App "C" [Ref g, Ref Sum]@ (contrast 注釈・正本 AST と同形)。+-- [English]: Turns a categorical index term into the AST: @C(g)@ →+-- @Ref g@ (unannotated treatment), @C(g, Sum)@ →+-- @App "C" [Ref g, Ref Sum]@ (contrast annotation, same shape as the+-- canonical AST).+catTerm :: Text -> Maybe Text -> Term+catTerm nm Nothing = Ref nm+catTerm nm (Just c) = App "C" [Ref nm, Ref c]++-- | [日本語]: 因子のデータ式部分 (連続/関数/I)。 factor/basis はここに出さない。+-- [English]: The data-expression part of a factor (continuous \/+-- function \/ I). Factor\/basis are not emitted here.+factorData :: RFactor -> [Term]+factorData (RVar x) = [Ref x]+factorData (RFun f a) = [App f [a]]+factorData (RI t) = [t]+factorData _ = []++-- | [日本語]: 合成パラメータ名か。+-- [English]: Whether this is a synthesized parameter name.+isSynth :: Text -> Bool+isSynth n = "_p" `isPrefixOf` T.unpack n++-- | [日本語]: Term 中の Ref 名 (data 変数収集用)。+-- [English]: Ref names within a Term (for collecting data variables).+refNamesT :: Term -> [Text]+refNamesT t = case t of+ Ref x -> [x]+ Lit _ -> []+ App "C" (Ref x : _) -> [x] -- contrast 注釈: factor 名のみ (coding 名は除外)+ App _ as -> concatMap refNamesT as+ Index a b -> refNamesT a ++ refNamesT b+ Neg a -> refNamesT a+ Bin _ a b -> refNamesT a ++ refNamesT b++-- | [日本語]: 文字列 → 'Formula' (R front-end)。+-- [English]: String → 'Formula' (R front-end).+parseRFormula :: Text -> Either String Formula+parseRFormula txt = case parse pRFormula "<r-formula>" txt of+ Left e -> Left (errorBundlePretty e)+ Right f -> Right f
+ src/Hanalyze/Model/GAM.hs view
@@ -0,0 +1,387 @@+{-# LANGUAGE OverloadedStrings #-}+-- |+-- Module : Hanalyze.Model.GAM+-- Description : 一般化加法モデル (Generalized Additive Model, GAM)+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- [日本語]: 一般化加法モデル (Generalized Additive Model, GAM)。+--+-- @y = β₀ + Σ_j s_j(x_j) + ε@ で、 各平滑項 @s_j(x_j) = B_j(x_j) γ_j@ は+-- 任意の基底 @B_j@ (B-spline / 自然3次 / 多項式 / Fourier / RBF) について+-- __係数について線形__ である。 そのため基底は 'GAMBasis' として抽象化され、+-- fit・predict・成分ごとの経路は、 いずれも訓練 @x@ から学習された実体化済み+-- 基底 ('BasisRealized') に基づいて分岐する。 これにより新しい点での予測は+-- __同一の基底行列__ を再構築する。+--+-- 設計:+--+-- - 各予測子 @x_j@ について、 'GAMBasis' に従って基底行列 @B_j@ (@n × m_j@)+-- を構築する。+-- - 単一の設計行列 @X = [1 | B_1 | B_2 | ... | B_p]@ (@1 + Σ m_j@ 列) に+-- 結合する。+-- - Ridge 正則化 OLS:+-- @β = (XᵀX + λ P)⁻¹ Xᵀ y@、 @P = diag(0,1,…,1)@ (切片は免除)。+-- 同一の @λ@ が全基底の平滑度を安定化する。+-- - @λ@ は固定 ('FixedL') か、 GCV ('GCV') により基底の実体化から選ぶかを+-- 選択できる。+-- - 予測: 各特徴の寄与 @s_j(x_j)@ は個別に抽出でき、 各因子の効果の可視化に+-- 使える。+--+-- 注: 識別性のため、各基底は中央化 (列平均を引く) する。+-- これで β₀ は y の平均、s_j は変動成分のみを表す。+--+-- [English]: Generalized Additive Model (GAM).+--+-- @y = β₀ + Σ_j s_j(x_j) + ε@ where each smooth term @s_j(x_j) = B_j(x_j) γ_j@+-- is __linear in its coefficients__ for *any* basis @B_j@ (B-spline / natural+-- cubic / polynomial / Fourier / RBF). The basis is therefore abstracted as+-- 'GAMBasis'; the fit, predict, and per-component paths all dispatch on the+-- realized basis ('BasisRealized') learned from the training @x@, so+-- prediction at new points rebuilds the __same__ basis matrix.+--+-- Design:+--+-- - For each predictor @x_j@, build a basis matrix @B_j@ (@n × m_j@) per+-- 'GAMBasis'.+-- - Stack into a single design matrix+-- @X = [1 | B_1 | B_2 | ... | B_p]@ (@1 + Σ m_j@ columns).+-- - Ridge-regularized OLS:+-- @β = (XᵀX + λ P)⁻¹ Xᵀ y@ with @P = diag(0,1,…,1)@ (intercept exempt).+-- The same @λ@ stabilizes every basis (smoothness regularization).+-- - @λ@ may be fixed ('FixedL') or chosen by GCV ('GCV') from the realized+-- basis.+-- - Prediction: the per-feature contribution @s_j(x_j)@ can be extracted+-- individually for visualization of each factor's effect.+--+-- Note: for identifiability, each basis is centered (column means+-- subtracted). This makes β₀ the mean of y, and each s_j purely the+-- variation component.+module Hanalyze.Model.GAM+ ( -- * 基底の抽象化+ GAMBasis (..)+ , BasisRealized (..)+ , GAMLambda (..)+ -- * フィット結果+ , GAMFit (..)+ -- * フィット+ , fitGAM+ , fitGAMWith+ , fitGAMAuto+ -- * 予測+ , predictGAM+ , predictGAMSE+ , predictGAMComponent+ ) where++import qualified Data.Vector as V+import qualified Numeric.LinearAlgebra as LA+import Hanalyze.Model.Spline (bsplineBasis, naturalSplineBasis, equalSpacedKnots)++-- ---------------------------------------------------------------------------+-- 基底の抽象化+-- ---------------------------------------------------------------------------++-- | [日本語]: 平滑項 @s_j(x_j)@ の基底の種類 (係数について線形なものを列挙)。+-- 各々 @x → 基底行列 (n × m)@ を与える。+-- [English]: The kind of basis for the smooth term @s_j(x_j)@ (enumerates+-- those linear in their coefficients). Each gives @x → basis matrix (n × m)@.+data GAMBasis+ = BSplineB Int Int -- ^ [日本語]: @BSplineB degree nKnots@: degree 次 B-spline (内部ノット @nKnots@)。 [English]: @BSplineB degree nKnots@: a degree-th order B-spline (with @nKnots@ interior knots).+ | NaturalCubicB Int -- ^ [日本語]: @NaturalCubicB nKnots@: 自然3次回帰スプライン (内部ノット @nKnots@)。 [English]: @NaturalCubicB nKnots@: a natural cubic regression spline (with @nKnots@ interior knots).+ | PolyB Int -- ^ [日本語]: @PolyB degree@: 直交化なしの多項式 (@[t,t²,…,t^degree]@・@t∈[-1,1]@ にスケール)。 [English]: @PolyB degree@: an unorthogonalized polynomial (@[t,t²,…,t^degree]@; scaled to @t∈[-1,1]@).+ | FourierB Int -- ^ [日本語]: @FourierB nHarmonics@: Fourier 基底 (@sin/cos@ を @nHarmonics@ 次まで)。 [English]: @FourierB nHarmonics@: a Fourier basis (@sin/cos@ up to order @nHarmonics@).+ | RBFB Int Double -- ^ [日本語]: @RBFB nCenters bandwidthRel@: ガウス RBF (等間隔中心・帯域 = 中心間隔×bandwidthRel)。 [English]: @RBFB nCenters bandwidthRel@: a Gaussian RBF (equally spaced centers; bandwidth = center spacing × bandwidthRel).+ deriving (Show, Eq)++-- | [日本語]: 学習済み基底。 訓練 @x@ から決まる具体パラメタ (ノット/中心/レンジ) を保持し、+-- 任意の新 @x@ に対し同一の基底行列を再構築できる ('evalBasis')。+-- [English]: A fitted basis. Holds the concrete parameters (knots/centers/+-- range) determined from the training @x@, so the same basis matrix can be+-- rebuilt for any new @x@ ('evalBasis').+data BasisRealized+ = RBSpline Int [Double] -- ^ [日本語]: degree, 内部ノット列。 [English]: degree, list of interior knots.+ | RNaturalCubic [Double] -- ^ [日本語]: ノット列。 [English]: list of knots.+ | RPoly Int Double Double -- ^ [日本語]: degree, xmin, xmax (@t = 2(x−lo)/(hi−lo)−1@ にスケール)。 [English]: degree, xmin, xmax (scaled as @t = 2(x−lo)/(hi−lo)−1@).+ | RFourier Int Double Double -- ^ [日本語]: nHarmonics, xmin, period (@t = (x−lo)/period@)。 [English]: nHarmonics, xmin, period (@t = (x−lo)/period@).+ | RRBF [Double] Double -- ^ [日本語]: 中心列, 帯域 (絶対値)。 [English]: list of centers, bandwidth (absolute value).+ deriving (Show)++-- | [日本語]: @λ@ の決め方。 'FixedL' は固定値、 'GCV' は一般化交差検証で 1 次元探索する。+-- [English]: How @λ@ is chosen. 'FixedL' is a fixed value; 'GCV' performs a+-- 1-D search via generalized cross-validation.+data GAMLambda+ = FixedL Double -- ^ [日本語]: 固定 @λ@ (@0@ で罰則なし)。 [English]: A fixed @λ@ (@0@ disables the penalty).+ | GCV -- ^ [日本語]: GCV @λ* = argmin_λ n·RSS(λ)/(n−edf(λ))²@ を log グリッド探索。 [English]: Searches a log grid for GCV @λ* = argmin_λ n·RSS(λ)/(n−edf(λ))²@.+ deriving (Show, Eq)++-- | [日本語]: 'GAMBasis' を訓練 @x@ で実体化する。+-- [English]: Realizes a 'GAMBasis' at the training @x@.+realizeBasis :: GAMBasis -> V.Vector Double -> BasisRealized+realizeBasis b xs =+ let lo = if V.null xs then 0 else V.minimum xs+ hi = if V.null xs then 1 else V.maximum xs+ in case b of+ BSplineB deg nK -> RBSpline deg (equalSpacedKnots (nK + 2) lo hi)+ -- 自然3次は基底に ≥3 ノット必要 (端2 + 内部)。 等間隔で nK+2 点 (両端含む)。+ NaturalCubicB nK -> RNaturalCubic (equalSpacedKnots (max 3 (nK + 2)) lo hi)+ PolyB deg -> RPoly (max 1 deg) lo hi+ FourierB h -> RFourier (max 1 h) lo (let p = hi - lo in if p <= 0 then 1 else p)+ RBFB c bwRel ->+ let nc = max 2 c+ centers = equalSpacedKnots nc lo hi+ spacing = if nc < 2 then 1 else (hi - lo) / fromIntegral (nc - 1)+ bw = (if spacing <= 0 then 1 else spacing) * (if bwRel <= 0 then 1 else bwRel)+ in RRBF centers bw++-- | [日本語]: 学習済み基底で新 @x@ の基底行列 (@n × m@・__未中央化__) を作る。+-- [English]: Builds the basis matrix (@n × m@; __not centered__) for a new+-- @x@ from the fitted basis.+evalBasis :: BasisRealized -> V.Vector Double -> LA.Matrix Double+evalBasis br xs = case br of+ RBSpline deg knots -> bsplineBasis deg knots xs+ -- naturalSplineBasis は先頭に定数列を含む → GAM は別途切片を持つので落とす。+ RNaturalCubic knots ->+ let m = naturalSplineBasis knots xs+ in if LA.cols m <= 1 then m else m LA.?? (LA.All, LA.Drop 1)+ RPoly deg lo hi ->+ let denom = hi - lo+ t x = if denom <= 0 then 0 else 2 * (x - lo) / denom - 1+ row x = [ t x ^^ k | k <- [1 .. deg] ]+ in LA.fromLists [ row x | x <- V.toList xs ]+ RFourier h lo period ->+ let t x = (x - lo) / period+ row x = concat [ [ sin (2 * pi * fromIntegral k * t x)+ , cos (2 * pi * fromIntegral k * t x) ]+ | k <- [1 .. h] ]+ in LA.fromLists [ row x | x <- V.toList xs ]+ RRBF centers bw ->+ let row x = [ exp (negate 0.5 * ((x - c) / bw) ^ (2 :: Int)) | c <- centers ]+ in LA.fromLists [ row x | x <- V.toList xs ]++-- ---------------------------------------------------------------------------+-- 型+-- ---------------------------------------------------------------------------++-- | GAM fit result.+data GAMFit = GAMFit+ { gamDegree :: Int -- ^ [日本語]: (後方互換) 先頭 B-spline 項の degree。非 B-spline は 0。 [English]: (Backward compatibility) The degree of the leading B-spline term. 0 for non-B-spline bases.+ , gamKnots :: [[Double]] -- ^ [日本語]: (後方互換) 項ごとのノット列。ノットを持たない基底は @[]@。 [English]: (Backward compatibility) The knot list per term. @[]@ for bases without knots.+ , gamBases :: [BasisRealized] -- ^ [日本語]: __評価の正典__: 項ごとの学習済み基底。 [English]: The __canonical source for evaluation__: the fitted basis per term.+ , gamBetas :: [LA.Vector Double] -- ^ Per-feature spline coefficients @γ_j@.+ , gamColMeans :: [LA.Vector Double] -- ^ Per-feature column means of @B_j@ (for centering).+ , gamIntercept :: Double -- ^ Intercept @β₀@.+ , gamYHat :: LA.Vector Double -- ^ Fitted values.+ , gamResid :: LA.Vector Double -- ^ Residuals.+ , gamR2 :: Double -- ^ R².+ , gamLambda :: Double -- ^ [日本語]: Ridge penalty @λ@ used (GCV のときは選ばれた値)。 [English]: The ridge penalty @λ@ used (the value chosen by GCV, when applicable).+ , gamEdf :: Double -- ^ [日本語]: 有効自由度 @tr(S_λ)@ (GCV 用)。 [English]: The effective degrees of freedom @tr(S_λ)@ (used by GCV).+ , gamCov :: LA.Matrix Double -- ^ [日本語]: 係数共分散 @Vβ = (XᵀX+λP)⁻¹·φ̂@+ -- (mgcv 流 Bayesian CI 用・@φ̂ = RSS/(n−edf)@)。+ -- [English]: Coefficient covariance @Vβ = (XᵀX+λP)⁻¹·φ̂@+ -- (for mgcv-style Bayesian CIs; @φ̂ = RSS/(n−edf)@).+ } deriving (Show)++-- ---------------------------------------------------------------------------+-- フィット+-- ---------------------------------------------------------------------------++-- | [日本語]: Fit a GAM (B-spline 基底固定の薄ラッパ・後方互換)。+-- [English]: Fits a GAM (a thin wrapper fixed to a B-spline basis, kept for+-- backward compatibility).+fitGAM :: Int -- ^ B-spline degree (3 = cubic recommended).+ -> Int -- ^ Number of interior knots (e.g. 5).+ -> Double -- ^ Ridge penalty @λ@ (0 disables regularization).+ -> [V.Vector Double] -- ^ Predictors @[x₁, x₂, …]@.+ -> V.Vector Double -- ^ Response @y@.+ -> GAMFit+fitGAM degree nKnots lambda xss =+ fitGAMWith [ BSplineB degree nKnots | _ <- xss ] lambda xss++-- | [日本語]: Fit a GAM with per-term基底を明示 + 固定 @λ@。+-- [English]: Fits a GAM with an explicit per-term basis and a fixed @λ@.+fitGAMWith :: [GAMBasis] -- ^ [日本語]: 項ごとの基底 (長さ = 予測子数)。 [English]: The basis per term (length = number of predictors).+ -> Double -- ^ Ridge penalty @λ@.+ -> [V.Vector Double] -- ^ Predictors.+ -> V.Vector Double -- ^ Response @y@.+ -> GAMFit+fitGAMWith bases lambda xss y =+ let realized = zipWith realizeBasis bases xss+ in fitCore realized lambda xss y++-- | [日本語]: Fit a GAM choosing @λ@ via 'GAMLambda' (FixedL / GCV)。+-- [English]: Fits a GAM, choosing @λ@ via 'GAMLambda' (FixedL / GCV).+fitGAMAuto :: [GAMBasis] -> GAMLambda -> [V.Vector Double] -> V.Vector Double -> GAMFit+fitGAMAuto bases lam xss y =+ let realized = zipWith realizeBasis bases xss+ in case lam of+ FixedL l -> fitCore realized l xss y+ GCV ->+ let grid = [ 10 ** e | e <- [(-4.0), (-3.5) .. 4.0 :: Double] ]+ score l = gamGCV (fitCore realized l xss y)+ best = snd (minimum [ (score l, l) | l <- grid ])+ in fitCore realized best xss y++-- | [日本語]: GCV 値 @n·RSS/(n−edf)²@ (小さいほど良い)。+-- [English]: The GCV value @n·RSS/(n−edf)²@ (smaller is better).+gamGCV :: GAMFit -> Double+gamGCV fit =+ let n = fromIntegral (LA.size (gamResid fit)) :: Double+ rss = LA.sumElements (LA.cmap (^ (2 :: Int)) (gamResid fit))+ den = n - gamEdf fit+ in if den <= 1e-9 then 1/0 else n * rss / (den * den)++-- | [日本語]: 学習済み基底列 + 固定 @λ@ で最小二乗を解く中核。+-- [English]: The core solver that fits least squares from a list of fitted+-- bases and a fixed @λ@.+fitCore :: [BasisRealized] -> Double -> [V.Vector Double] -> V.Vector Double -> GAMFit+fitCore realized lambda xss y =+ let n = V.length y+ -- 各 B_j (n × m_j) を構築 + 列平均で中央化+ basisRaw = zipWith evalBasis realized xss+ colMeans = [ LA.fromList+ [ LA.sumElements (LA.flatten (b LA.¿ [j])) / fromIntegral n+ | j <- [0 .. LA.cols b - 1] ]+ | b <- basisRaw ]+ basisCent = zipWith centerCols basisRaw colMeans++ -- 統合計画行列 X = [1 | B_1 | B_2 | ...]+ ones = LA.asColumn (LA.konst 1 n)+ x = foldl1 (LA.|||) (ones : basisCent)+ yLA = LA.fromList (V.toList y)+ p = LA.cols x++ -- Ridge: β = (XᵀX + λ I')⁻¹ Xᵀ y (intercept 列はペナルティ免除)+ pen = LA.diag (LA.fromList (0 : replicate (p - 1) lambda))+ xtx = LA.tr x LA.<> x+ lhs = xtx + pen+ lhsInv = LA.inv lhs -- (XᵀX+λP)⁻¹ (edf と Vβ で共用)+ xty = LA.tr x LA.#> yLA+ beta = lhsInv LA.#> xty++ -- 有効自由度 edf = tr(S_λ) = tr((XᵀX+λP)⁻¹ XᵀX)+ edf = sumDiag (lhsInv LA.<> xtx)++ -- intercept = β[0]、各特徴の γ_j を切り出す+ mSizes = [ LA.cols b | b <- basisRaw ]+ starts = scanl (+) 1 mSizes -- intercept は 0+ betas = [ LA.subVector (starts !! j) (mSizes !! j) beta+ | j <- [0 .. length xss - 1] ]+ intercept = beta LA.! 0++ yhat = x LA.#> beta+ resid = yLA - yhat+ yMean = LA.sumElements yLA / fromIntegral n+ tss = LA.sumElements (LA.cmap (\v -> (v - yMean) ^ (2 :: Int)) yLA)+ rss = LA.sumElements (LA.cmap (^ (2 :: Int)) resid)+ r2 = if tss < 1e-12 then 0 else 1 - rss / tss+ -- CI 用係数共分散 Vβ = (XᵀX+λP)⁻¹·φ̂ (mgcv 流 Bayesian・φ̂ = RSS/(n−edf))。+ dfRes = fromIntegral n - edf+ phi = if dfRes > 1e-9 then rss / dfRes else rss+ cov = LA.scale phi lhsInv+ in GAMFit+ { gamDegree = case realized of { (RBSpline d _ : _) -> d; _ -> 0 }+ , gamKnots = map knotsOf realized+ , gamBases = realized+ , gamBetas = betas+ , gamColMeans = colMeans+ , gamIntercept = intercept+ , gamYHat = yhat+ , gamResid = resid+ , gamR2 = r2+ , gamLambda = lambda+ , gamEdf = edf+ , gamCov = cov+ }+ where+ -- 列平均を引いて中央化+ centerCols :: LA.Matrix Double -> LA.Vector Double -> LA.Matrix Double+ centerCols m mu =+ let cols = LA.toColumns m+ centered = zipWith (\c muVal -> LA.cmap (\v -> v - muVal) c)+ cols (LA.toList mu)+ in LA.fromColumns centered+ sumDiag :: LA.Matrix Double -> Double+ sumDiag = LA.sumElements . LA.takeDiag+ knotsOf :: BasisRealized -> [Double]+ knotsOf (RBSpline _ k) = k+ knotsOf (RNaturalCubic k) = k+ knotsOf _ = []++-- ---------------------------------------------------------------------------+-- 予測+-- ---------------------------------------------------------------------------++-- | Predict at new predictors.+predictGAM :: GAMFit -> [V.Vector Double] -> V.Vector Double+predictGAM fit xss =+ let n = if null xss then 0 else V.length (head xss)+ contributions = zipWith4 componentVec+ (gamBases fit) (gamBetas fit) (gamColMeans fit) xss+ total = foldl' (V.zipWith (+)) (V.replicate n (gamIntercept fit))+ contributions+ in total+ where+ foldl' f z [] = z+ foldl' f z (a:as) = let !z' = f z a in foldl' f z' as+ componentVec :: BasisRealized -> LA.Vector Double -> LA.Vector Double+ -> V.Vector Double -> V.Vector Double+ componentVec br gamma mu xs =+ let b = evalBasis br xs+ n' = LA.rows b+ ys = b LA.#> gamma+ shiftV = LA.dot mu gamma+ in V.fromList [ ys LA.! i - shiftV | i <- [0 .. n' - 1] ]++-- | [日本語]: Predict + 各評価点の __pointwise standard error__ を返す (CI 帯用)。+--+-- 評価点設計行列 @Xeval = [1 | (B_j − colMean_j) | …]@ を fit と同じ中央化で組み、+-- @se_i = √(b_i Vβ b_iᵀ)@ ('gamCov' = @Vβ@)。 中心 @μ̂@ は 'predictGAM' と一致する。+-- 信頼水準 → 臨界値 (t) の掛け算は呼び出し側 (描画層) が行う。+--+-- [English]: Predicts and returns the __pointwise standard error__ at each+-- evaluation point (for CI bands).+--+-- Builds the evaluation design matrix @Xeval = [1 | (B_j − colMean_j) | …]@+-- with the same centering as the fit, giving @se_i = √(b_i Vβ b_iᵀ)@+-- ('gamCov' = @Vβ@). The center @μ̂@ matches 'predictGAM'. Multiplying by the+-- critical value (t) for a confidence level is left to the caller (the+-- plotting layer).+predictGAMSE :: GAMFit -> [V.Vector Double] -> (V.Vector Double, V.Vector Double)+predictGAMSE fit xss =+ let nEval = if null xss then 0 else V.length (head xss)+ mu = predictGAM fit xss+ basisRaw = zipWith evalBasis (gamBases fit) xss+ basisCent = zipWith subtractColMeans basisRaw (gamColMeans fit)+ ones = LA.asColumn (LA.konst 1 nEval)+ xEval = foldl1 (LA.|||) (ones : basisCent) -- nEval × p+ m1 = xEval LA.<> gamCov fit -- nEval × p+ varVec = [ LA.dot rM rX | (rM, rX) <- zip (LA.toRows m1) (LA.toRows xEval) ]+ se = map (sqrt . max 0) varVec+ in (mu, V.fromList se)++-- | [日本語]: 各列から学習時の列平均を引く (評価点を fit と同じ中央化にする)。+-- [English]: Subtracts the training-time column mean from each column+-- (applies the same centering to the evaluation points as the fit).+subtractColMeans :: LA.Matrix Double -> LA.Vector Double -> LA.Matrix Double+subtractColMeans m mu =+ LA.fromColumns (zipWith (\c muVal -> LA.cmap (subtract muVal) c)+ (LA.toColumns m) (LA.toList mu))++-- | The contribution @s_j(x)@ from feature @j@ only (without the intercept).+predictGAMComponent :: GAMFit -> Int -> V.Vector Double -> V.Vector Double+predictGAMComponent fit j xs+ | j < 0 || j >= length (gamBetas fit) = V.empty+ | otherwise =+ let b = evalBasis (gamBases fit !! j) xs+ gamma = gamBetas fit !! j+ mu = gamColMeans fit !! j+ ys = b LA.#> gamma+ shiftV = LA.dot mu gamma+ n = LA.rows b+ in V.fromList [ ys LA.! i - shiftV | i <- [0 .. n - 1] ]++-- 4-引数 zipWith (base に無いので局所定義)。+zipWith4 :: (a -> b -> c -> d -> e) -> [a] -> [b] -> [c] -> [d] -> [e]+zipWith4 f (a:as) (b:bs) (c:cs) (d:ds) = f a b c d : zipWith4 f as bs cs ds+zipWith4 _ _ _ _ _ = []
+ src/Hanalyze/Model/GARCH.hs view
@@ -0,0 +1,222 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE BangPatterns #-}+-- |+-- Module : Hanalyze.Model.GARCH+-- Description : GARCH(1,1) 条件付き分散モデル (Generalized AutoRegressive Conditional Heteroskedasticity)+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- GARCH(1,1) — Generalized AutoRegressive Conditional Heteroskedasticity.+--+-- Bollerslev (1986). Models time-varying conditional variance for a+-- (de-meaned) return series:+--+-- @+-- y_t = μ + ε_t, ε_t = σ_t · z_t, z_t ~ N(0, 1)+-- σ²_t = ω + α · ε²_{t-1} + β · σ²_{t-1}+-- @+--+-- Constraints: @ω > 0, α ≥ 0, β ≥ 0, α + β < 1@ (stationarity).+--+-- Estimation by quasi-MLE under Gaussian innovations, optimized with+-- L-BFGS using numeric gradients. The constraints are enforced via a+-- reparametrization (softplus for ω, a stick-breaking sigmoid pair for+-- α and β capped at @0.999@).+--+-- @+-- import Hanalyze.Model.GARCH+--+-- let fit = fitGARCH ys -- GARCH(1,1) on the series+-- vh = forecastGARCH fit 10 -- 10-step ahead σ² forecast+-- @+--+-- == Implemented+--+-- * 'fitGARCH' (GARCH(1,1) Gaussian QMLE)+-- * 'forecastGARCH' (h-step-ahead conditional variance)+module Hanalyze.Model.GARCH+ ( GARCHFit (..)+ , fitGARCH+ , forecastGARCH+ ) where++import qualified Numeric.LinearAlgebra as LA+import qualified Hanalyze.Optim.LBFGS as LBFGS+import qualified Hanalyze.Optim.Common as OC+import System.IO.Unsafe (unsafePerformIO)++-- ---------------------------------------------------------------------------+-- Types+-- ---------------------------------------------------------------------------++-- | Fitted GARCH(1,1) model.+data GARCHFit = GARCHFit+ { gOmega :: !Double -- ^ Unconditional variance offset @ω@.+ , gAlpha :: !Double -- ^ ARCH coefficient @α@.+ , gBeta :: !Double -- ^ GARCH coefficient @β@.+ , gMu :: !Double -- ^ Mean of @y_t@.+ , gSigma2 :: !(LA.Vector Double) -- ^ In-sample conditional variance @σ²_t@.+ , gResiduals :: !(LA.Vector Double) -- ^ In-sample residuals @ε_t = y_t - μ@.+ , gLogLik :: !Double -- ^ Maximized Gaussian log-likelihood.+ } deriving (Show)++-- ---------------------------------------------------------------------------+-- Reparametrization helpers+-- ---------------------------------------------------------------------------++softplus :: Double -> Double+softplus x+ | x > 50 = x+ | x < -50 = exp x+ | otherwise = log1p (exp x)+ where log1p z = log (1 + z)++sigmoid :: Double -> Double+sigmoid x+ | x > 500 = 1+ | x < -500 = 0+ | otherwise = 1 / (1 + exp (-x))++-- | Map unconstrained @(θω, θα, θβ)@ to @(ω, α, β)@.+unpackParams :: Double -> Double -> Double -> (Double, Double, Double)+unpackParams t0 t1 t2 =+ let w = softplus t0+ s = sigmoid t1 -- total persistence ∈ (0, 1)+ sab = s * 0.999 -- α + β strictly < 1+ r = sigmoid t2 -- α-share of total ∈ (0, 1)+ a = sab * r+ b = sab * (1 - r)+ in (w, a, b)++-- Inverse of 'unpackParams' for warm-starting from a feasible point.+packParams :: Double -> Double -> Double -> (Double, Double, Double)+packParams w a b =+ let !sab = a + b+ !t0 = invSoftplus w+ !t1 = invSigmoid (sab / 0.999)+ !r = if sab > 0 then a / sab else 0.5+ !t2 = invSigmoid r+ in (t0, t1, t2)+ where+ invSoftplus y+ | y > 50 = y+ | otherwise = log (exp y - 1)+ invSigmoid p =+ let pc = min 0.99999 (max 1e-5 p)+ in log (pc / (1 - pc))++-- ---------------------------------------------------------------------------+-- Recursion+-- ---------------------------------------------------------------------------++-- | Run the GARCH(1,1) σ² recursion. @σ²_0@ is initialized to the sample+-- variance of @ε@ (a standard QMLE starting choice; alternatives such as+-- the unconditional variance @ω/(1-α-β)@ are equivalent in the limit).+recurseSigma2+ :: Double -- ^ ω.+ -> Double -- ^ α.+ -> Double -- ^ β.+ -> LA.Vector Double -- ^ ε.+ -> LA.Vector Double -- ^ σ² of same length as ε.+recurseSigma2 !w !a !b eps =+ let n = LA.size eps+ var0 = LA.dot eps eps / fromIntegral n+ sig0 = max 1e-12 var0+ step !s2Prev !ePrev = w + a * ePrev * ePrev + b * s2Prev+ go !i !s2Prev acc+ | i >= n = reverse acc+ | otherwise =+ let !s2 = if i == 0+ then sig0+ else step s2Prev (LA.atIndex eps (i - 1))+ in go (i + 1) s2 (s2 : acc)+ in LA.fromList (go 0 0 [])++-- | Negative Gaussian log-likelihood (to be minimized).+negLL+ :: LA.Vector Double -- ^ ε.+ -> Double -- ^ ω.+ -> Double -- ^ α.+ -> Double -- ^ β.+ -> Double+negLL eps w a b =+ let s2 = recurseSigma2 w a b eps+ n = LA.size eps+ ll = sum [ let s = max 1e-12 (LA.atIndex s2 i)+ e = LA.atIndex eps i+ in log (2 * pi * s) + e * e / s+ | i <- [0 .. n - 1] ]+ in 0.5 * ll++-- ---------------------------------------------------------------------------+-- Fitting+-- ---------------------------------------------------------------------------++-- | Fit a GARCH(1,1) model to @y@ by Gaussian QMLE. The mean @μ@ is+-- estimated as the sample mean; ω/α/β are jointly optimized by L-BFGS+-- with numeric gradients in an unconstrained reparametrization.+--+-- Starting values: @α = 0.05@, @β = 0.90@, @ω = (1 - α - β) · Var(ε)@+-- (so that the unconditional variance matches the sample variance).+fitGARCH :: LA.Vector Double -> GARCHFit+fitGARCH y =+ let n = LA.size y+ mu = LA.sumElements y / fromIntegral n+ eps = y - LA.scalar mu+ var0 = LA.dot eps eps / fromIntegral n+ a0 = 0.05+ b0 = 0.90+ w0 = max 1e-8 ((1 - a0 - b0) * var0)+ (t00, t10, t20) = packParams w0 a0 b0+ objL [t0, t1, t2] =+ let (w, a, b) = unpackParams t0 t1 t2+ in negLL eps w a b+ objL _ = error "fitGARCH: expected 3 parameters"+ cfg = LBFGS.defaultLBFGSConfig+ res = unsafePerformIO (LBFGS.runLBFGSNumeric cfg objL [t00, t10, t20])+ [t0, t1, t2] = OC.orBest res+ (w, a, b) = unpackParams t0 t1 t2+ s2 = recurseSigma2 w a b eps+ in GARCHFit+ { gOmega = w+ , gAlpha = a+ , gBeta = b+ , gMu = mu+ , gSigma2 = s2+ , gResiduals = eps+ , gLogLik = negate (OC.orValue res)+ }++-- ---------------------------------------------------------------------------+-- Forecasting+-- ---------------------------------------------------------------------------++-- | @h@-step-ahead conditional variance forecast. The recursion is+--+-- @+-- σ²_{T+1} = ω + α · ε²_T + β · σ²_T+-- σ²_{T+k} = ω + (α + β) · σ²_{T+k-1} (k ≥ 2)+-- @+--+-- so that the forecast converges to the unconditional variance+-- @ω / (1 - α - β)@.+forecastGARCH :: GARCHFit -> Int -> LA.Vector Double+forecastGARCH fit h+ | h <= 0 = LA.fromList []+ | otherwise =+ let w = gOmega fit+ a = gAlpha fit+ b = gBeta fit+ s2 = gSigma2 fit+ eps = gResiduals fit+ n = LA.size s2+ sT = LA.atIndex s2 (n - 1)+ eT = LA.atIndex eps (n - 1)+ s1 = w + a * eT * eT + b * sT+ go !k !prev+ | k > h = []+ | k == 1 = s1 : go 2 s1+ | otherwise =+ let !nxt = w + (a + b) * prev+ in nxt : go (k + 1) nxt+ in LA.fromList (go 1 0)
+ src/Hanalyze/Model/GLM.hs view
@@ -0,0 +1,719 @@+{-# LANGUAGE StrictData #-}+{-# LANGUAGE OverloadedStrings #-}+-- |+-- Module : Hanalyze.Model.GLM+-- Description : IRLS による一般化線形モデル (Generalized Linear Models)+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- Generalized Linear Models fit by Iteratively Reweighted Least Squares.+--+-- Provides Gaussian, Binomial and Poisson families with identity, log,+-- logit and sqrt link functions. 'runIRLS' returns both a 'FitResult' and+-- the inverse Fisher information @(XᵀWX)⁻¹@ used for standard errors and+-- predictive intervals. The multi-output variant 'fitGLMMulti' shares the+-- family / link across response columns and runs IRLS column-wise.+module Hanalyze.Model.GLM+ ( Family (..)+ , parseFamily+ , LinkFn (..)+ , parseLink+ , canonicalLink+ , GLMSolver (..)+ , fitGLM+ , fitGLMFull+ , fitGLMWith+ , fitGLMWithSmooth+ , runIRLS+ , runLBFGS_GLM+ -- * Multi-output (per-column IRLS; Family/Link shared)+ , GLMFitMulti (..)+ , fitGLMMulti+ -- * Diagnostic primitives (新規 export, request/090-CD)+ , Link+ , linkFnOf+ , glmDeviance+ , glmLogLik+ , glmVariance+ -- * Residuals + predict SE (request/090-AB)+ , glmPearsonResiduals+ , glmDevianceResiduals+ , GlmPredictCI (..)+ , predictGlmEtaWithSE+ , predictGlmMuWithCI+ ) where++import qualified DataFrame.Internal.DataFrame as DXD+import Hanalyze.DataIO.Convert (getDoubleVec)+import Hanalyze.Model.Core+import Hanalyze.Model.LM (multiPolyDesignMatrix, linspace, SmoothFit (..))++import Data.Text (Text)+import qualified Data.Vector as V+import qualified Data.Vector.Storable as VS+import qualified Numeric.LinearAlgebra as LA+import qualified Hanalyze.Stat.Cholesky as Chol+import qualified Hanalyze.Optim.LBFGS as LBFGS+import qualified Hanalyze.Optim.Common as OC+import System.IO.Unsafe (unsafePerformIO)+import Statistics.Distribution (quantile)+import Statistics.Distribution.Normal (normalDistr)+import Statistics.Distribution.StudentT (studentT)++-- ---------------------------------------------------------------------------+-- Family (response distribution)+-- ---------------------------------------------------------------------------++-- | GLM exponential-family distribution.+data Family = Gaussian | Binomial | Poisson+ deriving (Show, Eq)++-- | Parse a 'Family' name (case-sensitive).+parseFamily :: String -> Either String Family+parseFamily "gaussian" = Right Gaussian+parseFamily "binomial" = Right Binomial+parseFamily "poisson" = Right Poisson+parseFamily s = Left ("Unknown distribution '" ++ s ++ "'. Use: gaussian | binomial | poisson")++-- ---------------------------------------------------------------------------+-- Link function+-- ---------------------------------------------------------------------------++-- | GLM link function.+data LinkFn = Identity | Log | Logit | Sqrt+ deriving (Show, Eq)++-- | Parse a 'LinkFn' name.+parseLink :: String -> Either String LinkFn+parseLink "identity" = Right Identity+parseLink "log" = Right Log+parseLink "logit" = Right Logit+parseLink "sqrt" = Right Sqrt+parseLink s = Left ("Unknown link '" ++ s ++ "'. Use: identity | log | logit | sqrt")++-- | The canonical link function for a given family.+canonicalLink :: Family -> LinkFn+canonicalLink Gaussian = Identity+canonicalLink Binomial = Logit+canonicalLink Poisson = Log++-- Internal triple: (g, g⁻¹, g')+type Link = (Double -> Double, Double -> Double, Double -> Double)++-- | Resolve a 'LinkFn' to its triple @(g, g⁻¹, g')@.+linkFnOf :: LinkFn -> Link+linkFnOf Identity = (id, id, const 1.0)+linkFnOf Log = (log, exp, recip)+linkFnOf Logit = ( \x -> log (x / (1 - x))+ , \eta -> 1 / (1 + exp (-eta))+ , \mu -> 1.0 / (mu * (1 - mu))+ )+linkFnOf Sqrt = (sqrt, \eta -> eta * eta, \mu -> 0.5 / sqrt (max 1e-10 mu))++-- | Variance function @V(μ)@ for the given family.+varOf :: Family -> Double -> Double+varOf Gaussian _ = 1.0+varOf Binomial mu = mu * (1 - mu)+varOf Poisson mu = mu++-- | Public alias for the family variance @V(μ)@; see @varOf@. Exposed+-- so HPotfire diagnostics can compute Pearson-style standardisations+-- without re-implementing the family table.+glmVariance :: Family -> Double -> Double+glmVariance = varOf++-- | Clamp @μ@ to its valid range, avoiding boundary singularities.+safeMu :: Family -> LA.Vector Double -> LA.Vector Double+safeMu Binomial = LA.cmap (max 1e-8 . min (1 - 1e-8))+safeMu Poisson = LA.cmap (max 1e-8)+safeMu Gaussian = id++-- | Fused @safeMu (gInv eta)@ for canonical-link GLMs — single+-- @VS.map@ pass instead of @gInv@ followed by @safeMu@.+--+-- P36 (2026-05-07): the Poisson IRLS loop did+-- @safeMu (VS.map (exp . min 500) eta)@ each iteration, which is two+-- passes over an @n@-vector and two allocations. Most iterations+-- spend the bulk of time in 'irlsStep' BLAS calls anyway, but on the+-- @n=10000@ Poisson bench this fused form trims ~10% off per-iter μ+-- compute. For non-canonical links callers fall back to the generic+-- @safeMu . VS.map gInv@ path.+--+-- Currently only used for the Poisson canonical link — Binomial+-- empirically regresses under fusion (GHC inlines the two-pass split+-- form better on the logit bench) so it stays on the+-- @safeMu . VS.map gInv@ path.+muCanonical :: Family -> LA.Vector Double -> LA.Vector Double+muCanonical Poisson =+ VS.map (\e -> max 1e-8 (exp (min 500 e)))+muCanonical f =+ -- Generic fallback: callers should normally not hit this for+ -- Binomial / Gaussian; defined for totality.+ safeMu f+{-# INLINE muCanonical #-}++-- ---------------------------------------------------------------------------+-- IRLS+-- ---------------------------------------------------------------------------++maxIter :: Int+maxIter = 100++tol :: Double+tol = 1e-8++-- | Per-observation log-likelihood for the canonical-link GLMs we+-- support. Used for the IRLS log-likelihood-based early termination+-- (see 'runIRLS').+--+-- - Gaussian: @-½ (y − μ)²@ (constant terms dropped, harmless for the+-- ratio-based stopping rule).+-- - Binomial: @y log μ + (1 − y) log (1 − μ)@.+-- - Poisson : @y log μ − μ@ (Stirling term dropped).+--+-- List-based zipWith/sum accounted for 11.3% of time + 8.3% of+-- allocation on the n=10k logit profile. Replaced with vector-native+-- zipVectorWith + sumElements (no list materialization, single+-- BLAS-friendly pass). The family is dispatched once at the top-level+-- let-binding so the inner zipVectorWith sees a fully monomorphic+-- Double -> Double -> Double closure that GHC can specialize.+glmLogLik :: Family -> LA.Vector Double -> LA.Vector Double -> Double+glmLogLik family y mu = VS.sum (VS.zipWith f y mu)+ where+ f = case family of+ Gaussian -> \yi mi -> -0.5 * (yi - mi) ** 2+ Binomial -> \yi mi ->+ let m' = max 1e-12 (min (1 - 1e-12) mi)+ in yi * log m' + (1 - yi) * log (1 - m')+ Poisson -> \yi mi ->+ let m' = max 1e-12 mi+ in yi * log m' - m'++initBeta :: Family -> LinkFn -> LA.Vector Double -> Int -> LA.Vector Double+initBeta family linkFn y p =+ let (g, _, _) = linkFnOf linkFn+ yMean = LA.sumElements y / fromIntegral (LA.size y)+ yC = case family of+ Binomial -> max 1e-6 (min (1 - 1e-6) yMean)+ Poisson -> max 1e-6 yMean+ Gaussian -> yMean+ in LA.fromList (g yC : replicate (p - 1) 0.0)++-- | One IRLS step. Returns the updated @β@ together with the+-- corresponding @μ@ and the log-likelihood at the /input/ @β@.+--+-- Returning @μ_old@ and @ll_old@ here lets the convergence loop in+-- 'runIRLS' avoid an extra @x #> beta@ + @gInv μ@ + 'glmLogLik' pass+-- per iteration that the old API forced (see glmbench §1).+irlsStep :: Link -> (Double -> Double)+ -> (Family -> LA.Vector Double -> LA.Vector Double)+ -> Family -> LA.Matrix Double -> LA.Vector Double -> LA.Vector Double+ -> (LA.Vector Double, LA.Vector Double, Double)+irlsStep (_, gInv, gDeriv) varFn clamp family x y beta =+ -- Phase 12a (2026-05-06): replaced massiv-based map/zipWith3 with+ -- pure VS.{map,zipWith3}. Profile (Phase 11) showed+ -- @trivialScheduler_@ (massiv) consumed 9.8% of GLM IRLS time —+ -- pure overhead since 'compFor' was always 'Seq'. The replacement+ -- is single-pass, allocation-equivalent, and avoids the+ -- hmatrix↔massiv round trip.+ --+ -- P36 (2026-05-07): for Poisson canonical link, fuse @gInv@ and+ -- @safeMu@ into a single VS.map. Empirically Binomial regresses+ -- under the same fusion (GHC inlines the two-pass split better),+ -- so it stays on the generic path. The family pattern-match is+ -- hot-loop constant and gets specialized away by GHC.+ let eta = x LA.#> beta+ mu = case family of+ Poisson -> muCanonical Poisson eta+ _ -> clamp family (VS.map gInv eta)+ llHere = glmLogLik family y mu+ ws = VS.map (\m -> max 1e-10+ (1.0 / (gDeriv m ^ (2 :: Int) * varFn m)))+ mu+ zs = VS.zipWith3 (\ei yi mi -> ei + (yi - mi) * gDeriv mi)+ eta y mu+ -- Normal-equations form: solve (Xᵀ W X) β = Xᵀ W z via SPD Cholesky.+ -- Faster than solving (√W X) β = (√W z) with the general LSQ+ -- (dgels) when n ≫ p, which is the common GLM regime.+ wxT = LA.tr x * LA.asRow ws -- p × n with column scaling+ gMat = wxT LA.<> x -- p × p (SPD)+ bRhs = LA.asColumn (wxT LA.#> zs) -- p × 1+ betaNew = LA.flatten (Chol.cholSolveJitter gMat bRhs)+ in (betaNew, mu, llHere)++-- ---------------------------------------------------------------------------+-- Solver selection+-- ---------------------------------------------------------------------------++-- | GLM solver back-end.+--+-- - 'IRLS' — Iteratively Re-weighted Least Squares. Each iteration+-- builds and solves the SPD normal equations @XᵀWX β = XᵀWz@ via+-- 'Hanalyze.Stat.Cholesky.cholSolveJitter'. Quadratic convergence (= a full+-- Newton step every iteration); each iteration is @O(np²)@.+-- - 'LBFGS' — direct L-BFGS minimization of the negative+-- log-likelihood with the analytic gradient @Xᵀ(μ − y)@ (canonical+-- link). Per-iteration cost is @O(np)@. This is what @sklearn@+-- uses, and is the better choice in @n ≫ p²@ regimes once the+-- 'Hanalyze.Optim.LBFGS' inner loop is moved off Haskell-list operations.+--+-- Default solver: 'IRLS'. In the current bench regime (@n ≤ 10000@,+-- @p ≤ 20@), IRLS-with-Cholesky beats the pure-Haskell-list L-BFGS+-- because @O(np²)@ on small @p@ is dominated by hmatrix's BLAS calls+-- whereas the L-BFGS path pays per-step Haskell overhead. Switch to+-- 'LBFGS' for problems with @p > 50@ or when 'Hanalyze.Optim.LBFGS' itself is+-- vectorized.+data GLMSolver+ = IRLS+ | LBFGS+ deriving (Eq, Show)++defaultGLMSolver :: GLMSolver+defaultGLMSolver = IRLS++-- ---------------------------------------------------------------------------+-- L-BFGS direct GLM+-- ---------------------------------------------------------------------------++-- | Negative log-likelihood @-ℓ(β)@ for a canonical-link GLM.+glmNegLogLik :: Family -> LA.Matrix Double -> LA.Vector Double+ -> LA.Vector Double -> Double+glmNegLogLik family x y beta = negate (glmLogLik family y mu)+ where+ eta = x LA.#> beta+ mu = case family of+ Gaussian -> eta+ Binomial -> LA.cmap (\e -> 1 / (1 + exp (-e))) eta+ Poisson -> LA.cmap (\e -> exp (min 500 e)) eta++-- | Gradient of @-ℓ(β)@ for a canonical-link GLM:+--+-- @∇(-ℓ) = Xᵀ (μ - y)@+--+-- This identity holds for /every/ exponential-family GLM with the+-- canonical link, which is why L-BFGS is so attractive here — no+-- per-family branching is needed inside the gradient.+glmGrad :: Family -> LA.Matrix Double -> LA.Vector Double+ -> LA.Vector Double -> LA.Vector Double+glmGrad family x y beta =+ let eta = x LA.#> beta+ mu = case family of+ Gaussian -> eta+ Binomial -> LA.cmap (\e -> 1 / (1 + exp (-e))) eta+ Poisson -> LA.cmap (\e -> exp (min 500 e)) eta+ in LA.tr x LA.#> (mu - y)++-- | Fit a canonical-link GLM by minimizing the negative log-likelihood+-- with L-BFGS. This is the path that 'sklearn.linear_model.\*' uses+-- internally for logistic and Poisson regression and is markedly+-- faster than IRLS when @n ≫ p@ because each L-BFGS iteration costs+-- only @O(np)@ versus IRLS's @O(np²)@ for the @XᵀWX@ build.+--+-- Returns the same @(FitResult, fisherInv)@ pair as 'runIRLS'; the+-- Fisher information is computed once at the converged β via the same+-- Cholesky path used by IRLS, so downstream uses (CIs, WAIC, …) are+-- identical.+runLBFGS_GLM :: Family -> LA.Matrix Double -> LA.Vector Double+ -> (FitResult, LA.Matrix Double)+runLBFGS_GLM family x y =+ -- Only canonical-link GLMs are supported here (the simple gradient+ -- formula above relies on the canonical link). For non-canonical+ -- links (e.g. probit, sqrt link) the caller should use 'runIRLS'.+ let p = LA.cols x+ beta0 = initBeta family (canonicalLink family) y p+ -- Vector-native objective and gradient (no list conversion per+ -- L-BFGS step, which used to dominate runtime when @p ≈ 20@).+ fV b = glmNegLogLik family x y b+ gV b = glmGrad family x y b+ cfg = LBFGS.defaultLBFGSConfig+ { LBFGS.lbStop = OC.defaultStopCriteria+ { OC.stMaxIter = 200+ , OC.stTolFun = 1e-10+ , OC.stTolX = 1e-10 } }+ result = unsafePerformIO $+ LBFGS.runLBFGSWithV cfg fV gV beta0+ betaF = LA.fromList (OC.orBest result)+ mu = safeMu family $ case family of+ Gaussian -> x LA.#> betaF+ Binomial -> LA.cmap (\e -> 1 / (1 + exp (-e))) (x LA.#> betaF)+ Poisson -> LA.cmap (\e -> exp (min 500 e)) (x LA.#> betaF)+ resid = y - mu+ r2 = pseudoR2 family y mu+ fitR = FitResult (LA.asColumn betaF)+ (LA.asColumn mu)+ (LA.asColumn resid)+ (LA.fromList [r2])+ -- Fisher information at convergence (same path as IRLS).+ ws = VS.map (\m -> max 1e-10 (1.0 / (gDeriv m ^ (2::Int)+ * varOf family m)))+ mu+ wxT = LA.tr x * LA.asRow ws+ gMat = wxT LA.<> x+ fisher = Chol.cholSolveJitter gMat (LA.ident p)+ in (fitR, fisher)+ where+ (_, _, gDeriv) = linkFnOf (canonicalLink family)++-- ---------------------------------------------------------------------------++-- | Run IRLS to fit a single-output GLM. Returns both the fit result+-- and the inverse Fisher information @(XᵀWX)⁻¹@ used for standard+-- errors and credible/predictive intervals.+runIRLS :: Family -> LinkFn -> LA.Matrix Double -> LA.Vector Double+ -> (FitResult, LA.Matrix Double)+runIRLS family linkFn x y = (mkResult betaFinal muFinal, fisherInvFromMu muFinal)+ where+ link@(_, gInv, _) = linkFnOf linkFn+ step = irlsStep link (varOf family) safeMu family x y+ beta0 = initBeta family linkFn y (LA.cols x)+ isCanonicalLink = linkFn == canonicalLink family++ -- Mu at convergence boundary: mirror 'irlsStep' Poisson fusion+ -- when on the canonical link.+ muOf beta+ | isCanonicalLink && family == Poisson+ = muCanonical Poisson (x LA.#> beta)+ | otherwise = safeMu family (VS.map gInv (x LA.#> beta))++ -- 'converge' tracks β and the /previous/ iteration's log-likelihood.+ -- 'irlsStep' returns @(β_{k+1}, μ_at_β_k, ll_at_β_k)@: the updated β+ -- plus the current iter's μ + ll, all free side-products of the+ -- IRLS step itself. We pass @ll_at_β_k@ forward as the next iter's+ -- @llP@, eliminating the dedicated O(np) @llOf β@ pass per iter+ -- that the previous code performed (glmbench §1).+ --+ -- Convergence is checked on β-norm or relative ll change. The ll+ -- comparison is between ll(β_k) and ll(β_{k-1}) — one iteration+ -- lagged from the standard ll(β_{k+1}) vs ll(β_k) form, which is+ -- equivalent in steady state and avoids any extra μ pass in the+ -- inner loop.+ (betaFinal, muFinal) = converge maxIter True beta0 (glmLogLik family y (muOf beta0))++ -- ★初回反復だけ dLL 判定を無効化する: 'irlsStep' が返す @llHere@ は入力 β での+ -- @ll(β_k)@ なので、 初回は seed @llP = ll(β0)@ と一致し @dLL = 0 < tol@ で+ -- IRLS が 1 ステップで早期停止してしまう (= 28d1feb7 の per-iter ll 再利用+ -- リライトで混入した回帰)。 dB (β-norm) 判定は初回も正しいので残し、 dLL は+ -- 2 反復目以降 @ll(β_k) vs ll(β_{k-1})@ が揃ってから使う。+ converge 0 _ beta _ = (beta, muOf beta)+ converge n first beta llP =+ let (betaNew, _muHere, llHere) = step beta+ in if any notFinite (LA.toList betaNew)+ then (beta, muOf beta) -- divergence; keep last good β+ else+ let dB = LA.norm_2 (betaNew - beta)+ dLL = abs (llHere - llP) / max (abs llP) 1+ in if dB < tol || (not first && dLL < tol)+ then (betaNew, muOf betaNew) -- final μ pass once+ else converge (n - 1) False betaNew llHere++ notFinite b = isNaN b || isInfinite b++ mkResult beta mu =+ let resid = y - mu+ r2 = pseudoR2 family y mu+ in FitResult (LA.asColumn beta)+ (LA.asColumn mu)+ (LA.asColumn resid)+ (LA.fromList [r2])++ fisherInvFromMu mu =+ let (_, _, gDeriv) = link+ ws = VS.map (\m -> max 1e-10+ (1.0 / (gDeriv m ^ (2::Int) * varOf family m)))+ mu+ wxT = LA.tr x * LA.asRow ws -- p × n+ gMat = wxT LA.<> x -- p × p (SPD)+ p = LA.cols x+ in Chol.cholSolveJitter gMat (LA.ident p)++-- ---------------------------------------------------------------------------+-- Public API+-- ---------------------------------------------------------------------------++-- | Fit a GLM with the canonical link, returning just the 'FitResult'.+-- Uses @defaultGLMSolver@ (currently 'IRLS').+fitGLM :: Family -> LA.Matrix Double -> LA.Vector Double -> FitResult+fitGLM family x y =+ fst (fitGLMWith defaultGLMSolver family (canonicalLink family) x y)++-- | Like 'fitGLM' but also returns the inverse Fisher information+-- (Laplace-approximate posterior covariance). Used by the WAIC / LOO-CV+-- posterior-sampling helpers.+--+-- Routes through 'fitGLMWith' with @defaultGLMSolver@. When the+-- supplied 'LinkFn' is /not/ the canonical link of the family, the+-- 'LBFGS' solver is unsupported and the function silently falls back+-- to 'IRLS' so existing call sites that pass non-canonical links keep+-- working.+fitGLMFull :: Family -> LinkFn -> LA.Matrix Double -> LA.Vector Double+ -> (FitResult, LA.Matrix Double)+fitGLMFull family linkFn x y+ | linkFn == canonicalLink family = fitGLMWith defaultGLMSolver family linkFn x y+ | otherwise = runIRLS family linkFn x y++-- | Pick the solver explicitly. The 'LBFGS' path is only valid for the+-- canonical link of the family; non-canonical links transparently fall+-- back to 'IRLS'.+fitGLMWith+ :: GLMSolver -> Family -> LinkFn+ -> LA.Matrix Double -> LA.Vector Double+ -> (FitResult, LA.Matrix Double)+fitGLMWith IRLS family linkFn x y = runIRLS family linkFn x y+fitGLMWith LBFGS family linkFn x y+ | linkFn == canonicalLink family = runLBFGS_GLM family x y+ | otherwise = runIRLS family linkFn x y++-- | Fit GLM with specified distribution and link function.+-- Accepts multiple x columns with per-column polynomial degrees.+-- Returns SmoothFit only when there is exactly one x column (for scatter plot).+-- For PI with non-Gaussian families, falls back to CI (warn at call site).+fitGLMWithSmooth+ :: Family+ -> LinkFn+ -> [(Text, Int)] -- ^ [(x column name, polynomial degree)]+ -> Band -- ^ uncertainty band specification+ -> Int -- ^ grid resolution for smooth curve+ -> DXD.DataFrame+ -> Text -- ^ y column+ -> Maybe (FitResult, Maybe SmoothFit)+fitGLMWithSmooth family linkFn colDegs band nGrid df yCol = do+ xVecs <- mapM (flip getDoubleVec df . fst) colDegs+ yVec <- getDoubleVec yCol df++ let degrees = map snd colDegs+ dm = multiPolyDesignMatrix (zip xVecs degrees)+ y = LA.fromList (V.toList yVec)+ (res, fisher) = runIRLS family linkFn dm y+ (_, gInv, _) = linkFnOf linkFn+ beta = coefficientsV res+ n = LA.rows dm+ p = LA.cols dm++ -- PI falls back to CI for non-Gaussian (caller should warn)+ effectiveBand = case (band, family) of+ (PI lvl, Gaussian) -> PI lvl+ (PI lvl, _) -> CI lvl+ (b, _) -> b++ mSmooth = case (xVecs, degrees) of+ ([xVec], [deg]) -> Just (makeSmoothFit xVec deg)+ _ -> Nothing++ makeSmoothFit xVec deg =+ let xLa = LA.fromList (V.toList xVec)+ xMin = LA.minElement xLa+ xMax = LA.maxElement xLa+ span' = max 1e-8 (xMax - xMin)+ xGrid = V.fromList (linspace (xMin - 0.5*span') (xMax + 0.5*span') nGrid)+ dmG = multiPolyDesignMatrix [(xGrid, deg)]+ etaG = dmG LA.#> beta+ yGrid = map gInv (LA.toList etaG)+ gRows = LA.toRows dmG+ in case effectiveBand of+ NoBand ->+ SmoothFit (V.toList xGrid) yGrid yGrid yGrid False+ CI level ->+ let qVal = ciQuantile level+ halfW xi = qVal * sqrt (max 0 (xi `LA.dot` (fisher LA.#> xi)))+ etaL = LA.toList etaG+ lowers = zipWith (\eta xi -> gInv (eta - halfW xi)) etaL gRows+ uppers = zipWith (\eta xi -> gInv (eta + halfW xi)) etaL gRows+ in SmoothFit (V.toList xGrid) yGrid lowers uppers True+ PI level ->+ -- Gaussian only: add s²·1 term to CI variance+ let dfStat = fromIntegral (n - p) :: Double+ etaL = LA.toList etaG+ -- df<=0 (飽和) は s²=0/0・studentT が例外 → 帯を線に潰す (lo=hi=ĝ⁻¹(η))。+ in if dfStat <= 0+ then SmoothFit (V.toList xGrid) yGrid (map gInv etaL) (map gInv etaL) True+ else+ let s2 = let resV = residualsV res+ in (resV `LA.dot` resV) / dfStat+ tVal = quantile (studentT dfStat) ((1 + level) / 2)+ xtxi = LA.inv (LA.tr dm LA.<> dm)+ halfW xi = tVal * sqrt (s2 * (1 + xi `LA.dot` (xtxi LA.#> xi)))+ lowers = zipWith (\eta xi -> gInv (eta - halfW xi)) etaL gRows+ uppers = zipWith (\eta xi -> gInv (eta + halfW xi)) etaL gRows+ in SmoothFit (V.toList xGrid) yGrid lowers uppers True++ ciQuantile level = case family of+ -- 飽和 (df=n-p<=0) は studentT が例外 → 分位点 0 = CI 幅ゼロ (帯を線に潰す)。+ Gaussian | n - p <= 0 -> 0+ | otherwise -> quantile (studentT (fromIntegral (n - p))) ((1 + level) / 2)+ _ -> quantile (normalDistr 0 1) ((1 + level) / 2)++ return (res, mSmooth)++-- ---------------------------------------------------------------------------+-- Goodness of fit+-- ---------------------------------------------------------------------------++-- | McFadden-style pseudo-R² for GLMs.+pseudoR2 :: Family -> LA.Vector Double -> LA.Vector Double -> Double+pseudoR2 Gaussian y mu =+ let resid = y - mu+ yMean = LA.sumElements y / fromIntegral (LA.size y)+ dev = LA.cmap (subtract yMean) y+ in 1 - (resid `LA.dot` resid) / (dev `LA.dot` dev)+pseudoR2 family y mu =+ let yMean = LA.sumElements y / fromIntegral (LA.size y)+ muNull = LA.konst yMean (LA.size y)+ dFit = glmDeviance family y mu+ dNull = glmDeviance family y muNull+ in if dNull == 0 then 1 else 1 - dFit / dNull++-- | GLM deviance: @D(y, μ̂) = 2 (ℓ_sat − ℓ_model)@.+glmDeviance :: Family -> LA.Vector Double -> LA.Vector Double -> Double+glmDeviance Gaussian y mu =+ let r = y - mu in r `LA.dot` r+glmDeviance Binomial y mu =+ let muC = LA.cmap (max 1e-15 . min (1 - 1e-15)) mu+ term = VS.zipWith+ (\yi mui -> xlogy yi (yi / mui)+ + xlogy (1 - yi) ((1 - yi) / (1 - mui)))+ y muC+ in 2 * VS.sum term+glmDeviance Poisson y mu =+ let muC = LA.cmap (max 1e-15) mu+ term = VS.zipWith+ (\yi mui -> xlogy yi (yi / mui) - (yi - mui))+ y muC+ in 2 * VS.sum term++xlogy :: Double -> Double -> Double+xlogy 0 _ = 0+xlogy x y = x * log y++-- ---------------------------------------------------------------------------+-- 090-A: Residuals (request/090-AB)+-- ---------------------------------------------------------------------------++-- | Pearson residuals @(y - μ) / sqrt(V(μ))@.+glmPearsonResiduals+ :: Family+ -> LA.Vector Double -- ^ Observations @y@.+ -> LA.Vector Double -- ^ Fitted means @μ@.+ -> LA.Vector Double+glmPearsonResiduals family y mu =+ VS.zipWith (\yi mui ->+ let v = varOf family mui+ in if v <= 0 then 0 else (yi - mui) / sqrt v)+ y mu++-- | Deviance residuals @sign(y - μ) · sqrt(d_i)@ where @d_i@ is the+-- per-observation contribution to the deviance @D = Σ d_i@.+glmDevianceResiduals+ :: Family+ -> LA.Vector Double+ -> LA.Vector Double+ -> LA.Vector Double+glmDevianceResiduals family y mu =+ let perObs = pointwiseDeviance family y mu+ in VS.zipWith3 (\yi mui di -> signum (yi - mui) * sqrt (max 0 di))+ y mu perObs+ where+ pointwiseDeviance Gaussian ys ms =+ VS.zipWith (\yi mui -> let r = yi - mui in r * r) ys ms+ pointwiseDeviance Binomial ys ms =+ VS.zipWith+ (\yi mui ->+ let muC = max 1e-15 (min (1 - 1e-15) mui)+ in 2 * ( xlogy yi (yi / muC)+ + xlogy (1 - yi) ((1 - yi) / (1 - muC)) ))+ ys ms+ pointwiseDeviance Poisson ys ms =+ VS.zipWith+ (\yi mui ->+ let muC = max 1e-15 mui+ in 2 * (xlogy yi (yi / muC) - (yi - muC)))+ ys ms++-- ---------------------------------------------------------------------------+-- 090-B: Predict + SE (request/090-AB)+-- ---------------------------------------------------------------------------++-- | Prediction with Wald confidence interval on the response (μ) scale.+data GlmPredictCI = GlmPredictCI+ { gpMu :: !Double+ , gpLo :: !Double+ , gpHi :: !Double+ } deriving (Show)++-- | Linear-predictor prediction @η = xᵀβ@ with @SE = sqrt(xᵀ Σ x)@,+-- where @Σ@ is @(XᵀWX)⁻¹@ from 'fitGLMFull'. The intercept must be+-- present in @x@.+predictGlmEtaWithSE+ :: LA.Vector Double+ -> LA.Matrix Double+ -> LA.Vector Double+ -> (Double, Double)+predictGlmEtaWithSE beta sigma x =+ let eta = x `LA.dot` beta+ sigX = sigma LA.#> x+ seEta = sqrt (max 0 (x `LA.dot` sigX))+ in (eta, seEta)++-- | Wald CI on the response scale: build CI in @η@ space then transform+-- both endpoints through the inverse link.+predictGlmMuWithCI+ :: LinkFn+ -> Double+ -> LA.Vector Double+ -> LA.Matrix Double+ -> LA.Vector Double+ -> GlmPredictCI+predictGlmMuWithCI link level beta sigma x =+ let (eta, se) = predictGlmEtaWithSE beta sigma x+ z = waldZ level+ (_, gInv, _) = linkFnOf link+ mu = gInv eta+ lo = gInv (eta - z * se)+ hi = gInv (eta + z * se)+ in GlmPredictCI { gpMu = mu, gpLo = min lo hi, gpHi = max lo hi }++-- | Two-sided Wald z: @z = √2 · erf⁻¹(level)@ (so @level=0.95@ →+-- @1.95996…@). Uses Winitzki's rational approximation of @erf⁻¹@+-- (~1e-3 accuracy) to keep @statistics@ out of this module.+waldZ :: Double -> Double+waldZ lvl+ | lvl <= 0 || lvl >= 1 =+ error "predictGlmMuWithCI: confidence level must lie in (0, 1)"+ | otherwise = sqrt 2 * inverfApprox lvl++inverfApprox :: Double -> Double+inverfApprox x =+ let a = 0.147+ ln1 = log (1 - x * x)+ term1 = 2 / (pi * a) + ln1 / 2+ in signum x * sqrt (sqrt (term1 * term1 - ln1 / a) - term1)++-- ---------------------------------------------------------------------------+-- 多出力 GLM (列ごと IRLS)+-- ---------------------------------------------------------------------------++-- | Multi-output GLM result. The same family and link function are+-- used for all @q@ output columns; IRLS is run column-wise.+data GLMFitMulti = GLMFitMulti+ { gfmFamily :: Family+ , gfmLinkFn :: LinkFn+ , gfmFits :: [FitResult] -- ^ [日本語]: 列ごと FitResult [English]: Per-column FitResult+ , gfmFisher :: [LA.Matrix Double] -- ^ [日本語]: 列ごと (XᵀWX)⁻¹ [English]: Per-column (XᵀWX)⁻¹+ , gfmBeta :: LA.Matrix Double -- ^ [日本語]: 係数行列 p × q [English]: Coefficient matrix, p × q+ , gfmFitted :: LA.Matrix Double -- ^ [日本語]: 予測 n × q [English]: Fitted values, n × q+ , gfmResid :: LA.Matrix Double -- ^ [日本語]: 残差 n × q [English]: Residuals, n × q+ } deriving (Show)++-- | Fit a multi-output GLM. @Y@ has shape @n × q@; family and link+-- function are shared across all columns.+fitGLMMulti :: Family -> LinkFn -> LA.Matrix Double -> LA.Matrix Double+ -> GLMFitMulti+fitGLMMulti family linkFn x y =+ let q = LA.cols y+ perCol j = runIRLS family linkFn x (LA.flatten (y LA.¿ [j]))+ pairs = [perCol j | j <- [0 .. q - 1]]+ fits = map fst pairs+ fishs = map snd pairs+ betaM = LA.fromColumns [LA.flatten (coefficients f) | f <- fits]+ fitM = LA.fromColumns [LA.flatten (fitted f) | f <- fits]+ resM = LA.fromColumns [LA.flatten (residuals f) | f <- fits]+ in GLMFitMulti family linkFn fits fishs betaM fitM resM
+ src/Hanalyze/Model/GLMM.hs view
@@ -0,0 +1,823 @@+-- |+-- Module : Hanalyze.Model.GLMM+-- Description : 線形/一般化線形混合効果モデル (random intercept/slope)+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- Linear and generalized linear mixed-effects models.+--+-- 'fitLME' / 'fitGLMM' fit a __random-intercept__ mixed model: a single+-- scalar random effect per group (variance @σ²_u@, scalar BLUP @û_j@).+-- 'fitLME' is Gaussian via exact EM; 'fitGLMM' is non-Gaussian via Laplace.+--+-- 'fitLMEGeneral' / 'fitGLMMGeneral' generalise to+-- __vector random effects__ (random intercept + slopes): a per-group design block+-- @Z_j@ with an @r×r@ covariance matrix @G@ and a vector BLUP @b̂_j@. With+-- @r = 1@ (intercept only) they reduce exactly to 'fitLME' / 'fitGLMM'.+--+-- The multi-output variants ('fitLMEMulti', 'fitGLMMMulti') run the+-- random-intercept algorithm independently per response column.+module Hanalyze.Model.GLMM+ ( GLMMResult (..)+ , fitLME+ , fitGLMM+ , fitLMEDataFrame+ , fitGLMMDataFrame+ -- * General random effects (intercept + slope)+ , GLMMResultRE (..)+ , fitLMEGeneral+ , fitGLMMGeneral+ -- * Multi-output (per-column EM/Laplace; Family/Link shared)+ , GLMMResultMulti (..)+ , fitLMEMulti+ , fitGLMMMulti+ -- * Standard errors (request/100)+ , glmmFixedSE+ , glmmBLUPSE+ -- * Group helper (shared with Formula.Mixed)+ , buildGroups+ ) where++import qualified DataFrame.Internal.DataFrame as DXD+import Hanalyze.DataIO.Convert (getDoubleVec, getTextVec)+import Hanalyze.Model.Core (FitResult (..))+import Hanalyze.Model.GLM (Family (..), LinkFn (..))+import Hanalyze.Model.LM (multiPolyDesignMatrix)++import qualified Data.Map.Strict as Map+import qualified Data.Set as Set+import Data.Text (Text)+import qualified Data.Vector as V+import qualified Numeric.LinearAlgebra as LA++-- ---------------------------------------------------------------------------+-- Result type+-- ---------------------------------------------------------------------------++-- | Fit result for a random-intercept mixed model.+--+-- * LME (Gaussian): @y = Xβ + Zu + ε@, @u_j ~ N(0, σ²_u)@,+-- @ε_i ~ N(0, σ²)@.+-- * GLMM (non-Gaussian): @g(E[y|u]) = Xβ + Zu@, @u_j ~ N(0, σ²_u)@.+data GLMMResult = GLMMResult+ { glmmFixed :: FitResult -- ^ Fixed-effect fit (β, conditional+ -- fitted values, residuals, R²).+ , glmmRandVar :: Double -- ^ Random-intercept variance @σ²_u@.+ , glmmResidVar :: Double -- ^ Residual variance @σ²@ (1.0 for non-Gaussian families).+ , glmmBLUPs :: V.Vector Double -- ^ Best linear unbiased predictions+ -- @û_j@, aligned with 'glmmGroups'.+ , glmmGroups :: V.Vector Text -- ^ Sorted unique group labels.+ , glmmICC :: Double -- ^ Intraclass correlation (exact+ -- for Gaussian; link-scale+ -- approximation otherwise).+ } deriving (Show)++-- | Fit result for a __general__ mixed model with vector random effects+-- (random intercept + slopes).+--+-- * LME (Gaussian): @y_j = X_j β + Z_j b_j + ε_j@, @b_j ~ N(0, G)@,+-- @ε_i ~ N(0, σ²)@, where @Z_j@ is the per-group random-effect design+-- block (@n_j × r@) and @G@ is the @r×r@ random-effect covariance.+-- * GLMM (non-Gaussian): @g(E[y|b]) = X_j β + Z_j b_j@, @b_j ~ N(0, G)@.+--+-- With @r = 1@ and an intercept-only @Z@ this reduces exactly to the+-- scalar 'GLMMResult' (@reRandCov = [[σ²_u]]@, @reBLUPs@ a single column).+data GLMMResultRE = GLMMResultRE+ { reFixed :: FitResult -- ^ Fixed-effect fit (β, conditional+ -- fitted values, residuals, R²).+ , reRandCov :: LA.Matrix Double -- ^ Random-effect covariance @G@ (@r×r@).+ , reResidVar :: Double -- ^ Residual variance @σ²@ (1.0 for+ -- non-Gaussian families).+ , reBLUPs :: LA.Matrix Double -- ^ BLUPs @b̂@ as a @q×r@ matrix (row j =+ -- group j, aligned with 'reGroups').+ , reGroups :: V.Vector Text -- ^ Sorted unique group labels (length q).+ } deriving (Show)++-- ---------------------------------------------------------------------------+-- Group helpers (shared by LME and GLMM)+-- ---------------------------------------------------------------------------++-- | Parse grouping vector into (sorted unique labels, per-obs index, per-group sizes).+buildGroups :: V.Vector Text -> (V.Vector Text, V.Vector Int, V.Vector Int)+buildGroups gvec =+ -- Phase 11b (2026-05-14): Set-based dedup + sort, O(n log n) instead of+ -- the O(n²) 'nub'. Important for grouping vectors with thousands of IDs.+ let labels = V.fromList . Set.toAscList . Set.fromList . V.toList $ gvec+ q = V.length labels+ labelMap = Map.fromList (zip (V.toList labels) ([0..] :: [Int]))+ idx = V.map (\g -> Map.findWithDefault 0 g labelMap) gvec+ szMap = Map.fromListWith (+) (V.toList (V.map (\j -> (j, 1 :: Int)) idx))+ sizes = V.fromList [ Map.findWithDefault 0 j szMap | j <- [0..q-1] ]+ in (labels, idx, sizes)++-- | Group sums: (Zᵀv)_j = Σ_{i in group j} v_i+zGroupSums :: V.Vector Int -> V.Vector Double -> Int -> V.Vector Double+zGroupSums idx v q =+ let smap = Map.fromListWith (+) (V.toList (V.zipWith (,) idx v))+ in V.fromList [ Map.findWithDefault 0.0 j smap | j <- [0..q-1] ]++-- | Scatter random effects to observations: (Zu)_i = u_{g(i)}+zuScatter :: V.Vector Int -> V.Vector Double -> V.Vector Double+zuScatter idx u = V.map (u V.!) idx++-- ---------------------------------------------------------------------------+-- EM algorithm for LME (Gaussian, exact)+-- ---------------------------------------------------------------------------++maxEmIter :: Int+maxEmIter = 500++emTol :: Double+emTol = 1e-8++-- | Fit a random-intercept LME via EM (ML).+-- The E-step exploits the diagonal structure of the precision matrix for random intercepts:+-- P_jj = 1 / (1/σ²_u + n_j/σ²)+-- The M-step updates β by OLS on partial residuals; σ²_u and σ² analytically.+fitLME+ :: LA.Matrix Double -- X (design matrix, must include intercept column)+ -> LA.Vector Double -- y+ -> V.Vector Int -- per-observation group index (0-based)+ -> V.Vector Text -- sorted group labels (length q)+ -> V.Vector Int -- per-group observation counts (length q)+ -> GLMMResult+fitLME x y idx labels sizes =+ let n = LA.rows x+ q = V.length labels++ beta0 = LA.flatten (x LA.<\> LA.asColumn y)+ yMean = LA.sumElements y / fromIntegral n+ yDev = y - LA.konst yMean n+ ssTot = yDev `LA.dot` yDev+ varY = ssTot / fromIntegral n+ su2_0 = varY / 2+ s2_0 = varY / 2++ emStep (beta, su2, s2) =+ let pDiag = V.fromList [ 1.0 / (1.0/su2 + fromIntegral (sizes V.! j) / s2)+ | j <- [0..q-1] ]+ r0 = V.fromList . LA.toList $ y - x LA.#> beta+ ztR = zGroupSums idx r0 q+ utilde = V.zipWith (\pj sj -> pj * sj / s2) pDiag ztR+ zuU = LA.fromList . V.toList $ zuScatter idx utilde+ betaNew = LA.flatten (x LA.<\> LA.asColumn (y - zuU))+ trP = V.sum pDiag+ su2New = max 1e-8 $ (trP + V.sum (V.map (\u -> u*u) utilde)) / fromIntegral q+ r1 = y - x LA.#> betaNew - zuU+ trZPZt = V.sum (V.zipWith (\nj pj -> fromIntegral nj * pj) sizes pDiag)+ s2New = max 1e-8 $ (r1 `LA.dot` r1 + trZPZt) / fromIntegral n+ in (betaNew, su2New, s2New)++ converge 0 st = st+ converge k st@(b, su, s) =+ let st'@(b', su', s') = emStep st+ in if LA.norm_2 (b' - b) < emTol+ && abs (su' - su) < emTol+ && abs (s' - s) < emTol+ then st'+ else converge (k-1) st'++ (betaF, su2F, s2F) = converge maxEmIter (beta0, su2_0, s2_0)++ pDiagF = V.fromList [ 1.0 / (1.0/su2F + fromIntegral (sizes V.! j) / s2F)+ | j <- [0..q-1] ]+ r0F = V.fromList . LA.toList $ y - x LA.#> betaF+ ztRF = zGroupSums idx r0F q+ uF = V.zipWith (\pj sj -> pj * sj / s2F) pDiagF ztRF+ zuF = LA.fromList . V.toList $ zuScatter idx uF+ fittedV = x LA.#> betaF + zuF+ residV = y - fittedV+ ssResF = residV `LA.dot` residV+ r2 = if ssTot == 0 then 1.0 else 1.0 - ssResF / ssTot+ icc = su2F / (su2F + s2F)+ fitRes = FitResult (LA.asColumn betaF)+ (LA.asColumn fittedV)+ (LA.asColumn residV)+ (LA.fromList [r2])++ in GLMMResult fitRes su2F s2F uF labels icc++-- ---------------------------------------------------------------------------+-- General random effects (intercept + slope): vector EM for Gaussian LME+-- ---------------------------------------------------------------------------++-- | Fit a Gaussian LME with __vector__ random effects via EM (ML).+--+-- Per group @j@ the model is @y_j = X_j β + Z_j b_j + ε_j@ with+-- @b_j ~ N(0, G)@ (@G@ is @r×r@) and @ε ~ N(0, σ²I)@. The @Z@ argument holds+-- the raw random-effect design columns (usually a sub-block of @X@, e.g. the+-- intercept column plus the slope column for @(1+x|g)@); rows align with @X@.+--+-- EM (Laird-Ware), each step given @(β, G, σ²)@:+--+-- * E-step (per group, @r×r@): @P_j = (G⁻¹ + Z_jᵀZ_j/σ²)⁻¹@,+-- @b̂_j = P_j Z_jᵀ r_j / σ²@ with @r_j = y_j − X_j β@.+-- * M-step: @β = (XᵀX)⁻¹Xᵀ(y − Zb̂)@,+-- @G = (1/q) Σ_j (P_j + b̂_j b̂_jᵀ)@,+-- @σ² = (1/n)[Σ‖y_j − X_j β − Z_j b̂_j‖² + Σ tr(Z_jᵀZ_j P_j)]@.+--+-- With @r = 1@ and an intercept-only @Z@ this reproduces 'fitLME' exactly.+-- All linear algebra is hmatrix-native (no list-based fallbacks).+--+-- TODO (Phase 48 follow-up): this is ML; a REML variant would correct the+-- variance estimates for the fixed-effect degrees of freedom.+fitLMEGeneral+ :: LA.Matrix Double -- ^ X (fixed-effect design, must include intercept)+ -> LA.Matrix Double -- ^ Z (random-effect design, @n × r@; rows align with X)+ -> LA.Vector Double -- ^ y+ -> V.Vector Int -- ^ per-observation group index (0-based)+ -> V.Vector Text -- ^ sorted group labels (length q)+ -> GLMMResultRE+fitLMEGeneral x z y idx labels =+ let n = LA.rows x+ q = V.length labels+ r = LA.cols z+ members = precompMembers idx q n+ zRows = V.fromList (LA.toRows z) -- O(1) per-row access for scatter++ -- per-group X_j, Z_j, y_j (and Z_jᵀZ_j) precomputed once+ groupBlk j =+ let mem = members V.! j+ xj = x LA.? mem+ zj = z LA.? mem+ yj = LA.fromList [ y `LA.atIndex` i | i <- mem ]+ ztz = LA.tr zj LA.<> zj+ in (xj, zj, yj, ztz)+ blocks = V.fromList [ groupBlk j | j <- [0..q-1] ]++ -- initial values: OLS fixed fit, residual variance split intercept/resid+ beta0 = LA.flatten (x LA.<\> LA.asColumn y)+ yMean = LA.sumElements y / fromIntegral n+ yDev = y - LA.konst yMean n+ ssTot = yDev `LA.dot` yDev+ varY = ssTot / fromIntegral n+ g0 = LA.scale (varY / 2) (LA.ident r)+ s20 = varY / 2++ -- scatter (Zb̂)_i = Z_i · b̂_{g(i)}+ scatterZb bhats =+ LA.fromList [ (zRows V.! i) `LA.dot` (bhats V.! (idx V.! i)) | i <- [0..n-1] ]++ emStep (beta, gMat, s2) =+ let gInv = LA.inv gMat+ -- E-step: posterior cov P_j and mean b̂_j per group+ pbs = V.map (\(xj, zj, yj, ztz) ->+ let rj = yj - xj LA.#> beta+ pj = LA.inv (gInv + LA.scale (1/s2) ztz)+ bj = LA.scale (1/s2) (pj LA.#> (LA.tr zj LA.#> rj))+ in (pj, bj, ztz)) blocks+ bhats = V.map (\(_, bj, _) -> bj) pbs+ zb = scatterZb bhats+ -- M-step β+ betaN = LA.flatten (x LA.<\> LA.asColumn (y - zb))+ -- M-step G = (1/q) Σ (P_j + b̂_j b̂_jᵀ)+ gAcc = V.foldl' (\acc (pj, bj, _) -> acc + pj + LA.outer bj bj)+ (LA.konst 0 (r, r)) pbs+ gN = LA.scale (1 / fromIntegral q) gAcc+ -- M-step σ²: conditional residuals (using updated β) + trace term+ zbN = scatterZb bhats+ r1 = y - x LA.#> betaN - zbN+ trc = V.sum (V.map (\(pj, _, ztz) -> LA.sumElements (ztz * pj)) pbs)+ s2N = max 1e-10 $ (r1 `LA.dot` r1 + trc) / fromIntegral n+ in (betaN, gN, s2N)++ converge 0 st = st+ converge k st@(b, gM, s) =+ let st'@(b', gM', s') = emStep st+ in if LA.norm_2 (b' - b) < emTol+ && LA.norm_2 (LA.flatten (gM' - gM)) < emTol+ && abs (s' - s) < emTol+ then st'+ else converge (k-1) st'++ (betaF, gF, s2F) = converge maxEmIter (beta0, g0, s20)++ -- final BLUPs and conditional fit+ gInvF = LA.inv gF+ bhatsF = V.map (\(xj, zj, yj, ztz) ->+ let rj = yj - xj LA.#> betaF+ pj = LA.inv (gInvF + LA.scale (1/s2F) ztz)+ in LA.scale (1/s2F) (pj LA.#> (LA.tr zj LA.#> rj))) blocks+ zbF = scatterZb bhatsF+ fittedV = x LA.#> betaF + zbF+ residV = y - fittedV+ ssResF = residV `LA.dot` residV+ r2 = if ssTot == 0 then 1.0 else 1.0 - ssResF / ssTot+ fitRes = FitResult (LA.asColumn betaF)+ (LA.asColumn fittedV)+ (LA.asColumn residV)+ (LA.fromList [r2])+ blupMat = LA.fromRows (V.toList bhatsF) -- q×r++ in GLMMResultRE fitRes gF s2F blupMat labels++-- ---------------------------------------------------------------------------+-- Laplace approximation for non-Gaussian GLMM+-- ---------------------------------------------------------------------------++-- | Inverse link: μ = g⁻¹(η)+glmmInvLink :: LinkFn -> Double -> Double+glmmInvLink Identity η = η+glmmInvLink Log η = exp (min 500 η)+glmmInvLink Logit η = 1.0 / (1.0 + exp (-η))+glmmInvLink Sqrt η = η * η++-- | Forward link: η = g(μ)+glmmFwdLink :: LinkFn -> Double -> Double+glmmFwdLink Identity μ = μ+glmmFwdLink Log μ = log (max 1e-10 μ)+glmmFwdLink Logit μ = let c = max 1e-8 (min (1-1e-8) μ) in log (c / (1 - c))+glmmFwdLink Sqrt μ = sqrt (max 0 μ)++-- | Link derivative: g'(μ)+glmmLinkDeriv :: LinkFn -> Double -> Double+glmmLinkDeriv Identity _ = 1.0+glmmLinkDeriv Log μ = 1.0 / max 1e-10 μ+glmmLinkDeriv Logit μ = let c = max 1e-8 (min (1-1e-8) μ) in 1.0 / (c * (1 - c))+glmmLinkDeriv Sqrt μ = 0.5 / sqrt (max 1e-10 μ)++-- | GLM variance function: V(μ)+glmmVarFn :: Family -> Double -> Double+glmmVarFn Gaussian _ = 1.0+glmmVarFn Binomial μ = let c = max 1e-8 (min (1-1e-8) μ) in c * (1 - c)+glmmVarFn Poisson μ = max 1e-8 μ++-- | Clamp μ to numerically safe range.+glmmClampMu :: Family -> Double -> Double+glmmClampMu Binomial = max 1e-8 . min (1 - 1e-8)+glmmClampMu Poisson = max 1e-8+glmmClampMu Gaussian = id++-- | IRLS weight: w_i = 1 / (g'(μ)² V(μ))+glmmWeight :: Family -> LinkFn -> Double -> Double+glmmWeight family link μ =+ let d = glmmLinkDeriv link μ+ in max 1e-10 (1.0 / (d * d * glmmVarFn family μ))++-- | Score contribution: s_i = (y_i − μ_i) / (g'(μ_i) V(μ_i))+glmmScore :: Family -> LinkFn -> Double -> Double -> Double+glmmScore family link y μ =+ (y - μ) / (glmmLinkDeriv link μ * glmmVarFn family μ)++-- | ICC approximation for non-Gaussian models (on the link scale).+-- Binomial/logit: π²/3 is the variance of the standard logistic distribution.+-- Poisson/log: 1 is the log-scale residual variance (approximation).+iccApprox :: Family -> Double -> Double+iccApprox Gaussian su2 = su2 / (su2 + 1.0) -- placeholder; LME gives exact ICC+iccApprox Binomial su2 = su2 / (su2 + pi*pi/3.0)+iccApprox Poisson su2 = su2 / (su2 + 1.0)++-- | Precompute group member index lists (O(n) preprocessing).+precompMembers :: V.Vector Int -> Int -> Int -> V.Vector [Int]+precompMembers idx q n =+ let mmap = Map.fromListWith (++) [ (idx V.! i, [i]) | i <- [0..n-1] ]+ in V.fromList [ Map.findWithDefault [] j mmap | j <- [0..q-1] ]++maxNRIter :: Int+maxNRIter = 50++nrTol :: Double+nrTol = 1e-10++-- | Inner Newton-Raphson: find conditional mode û_j for one group.+-- Maximises Q_j(u) = Σ log p(y_i | g⁻¹(ηᵢ + u)) − u²/(2σ²_u)+-- NR step: u ← u + grad/hess where+-- grad = Σ s_i − u/σ²_u, hess = Σ w_i + 1/σ²_u+nrOneGroup :: Family -> LinkFn -> Double -> [Double] -> [Double] -> Double -> Double+nrOneGroup family link su2 etaFixed ys = go maxNRIter+ where+ clamp = glmmClampMu family+ gInv = glmmInvLink link++ go 0 u = u+ go k u =+ let mus = map (clamp . gInv . (+ u)) etaFixed+ grad = sum (zipWith (glmmScore family link) ys mus) - u / su2+ hess = sum (map (glmmWeight family link) mus) + 1.0 / su2+ delta = grad / hess+ u' = u + delta+ in if abs delta < nrTol then u' else go (k-1) u'++maxGLMMIter :: Int+maxGLMMIter = 200++glmmTol :: Double+glmmTol = 1e-7++-- | One outer GLMM iteration:+-- 1. NR(û) — find conditional modes given current β and σ²_u+-- 2. IRLS(β) — one IRLS step with random effects as offset+-- 3. EM(σ²_u) — Laplace-approximated posterior variance update+glmmStep+ :: Family -> LinkFn+ -> LA.Matrix Double -- X+ -> LA.Vector Double -- y+ -> V.Vector Int -- per-obs group index+ -> V.Vector [Int] -- per-group member index lists (precomputed)+ -> (LA.Vector Double, Double, V.Vector Double)+ -> (LA.Vector Double, Double, V.Vector Double)+glmmStep family link x y idx members (beta, su2, u) =+ let q = V.length u+ clamp = glmmClampMu family+ gInv = glmmInvLink link+ gD = glmmLinkDeriv link++ xBeta = x LA.#> beta+ etaFixedV = V.fromList (LA.toList xBeta)+ yV = V.fromList (LA.toList y)++ -- 1. Inner NR: update û_j for each group j+ uNew = V.fromList+ [ nrOneGroup family link su2+ [ etaFixedV V.! i | i <- members V.! j ]+ [ yV V.! i | i <- members V.! j ]+ (u V.! j)+ | j <- [0..q-1] ]++ -- 2. IRLS step for β (offset = Zû)+ -- z_adj_i = (y_i − μ_i) g'(μ_i) + (Xβ)_i (WLS target without offset)+ uScatter = LA.fromList . V.toList $ zuScatter idx uNew+ etaFull = xBeta + uScatter+ musV = V.map (clamp . gInv) (V.fromList (LA.toList etaFull))+ wsV = V.map (glmmWeight family link) musV+ xBetaV = V.fromList (LA.toList xBeta)+ zAdjV = V.zipWith3 (\yi mui xbi -> (yi - mui) * gD mui + xbi) yV musV xBetaV+ sqrtW = LA.diag (LA.fromList . V.toList $ V.map sqrt wsV)+ zAdj = LA.fromList (V.toList zAdjV)+ betaNew = LA.flatten $+ (sqrtW LA.<> x) LA.<\> LA.asColumn (sqrtW LA.#> zAdj)++ -- 3. EM-like σ²_u update using Laplace-approximated posterior variance+ -- ṽ_j = 1 / (Σ_{i∈j} w_i + 1/σ²_u) ≈ Var(u_j | y)+ -- σ²_u_new = Σ_j (ṽ_j + û_j²) / q+ etaNew = x LA.#> betaNew + uScatter+ musNewV = V.map (clamp . gInv) (V.fromList (LA.toList etaNew))+ wsNewV = V.map (glmmWeight family link) musNewV+ wSumsV = zGroupSums idx wsNewV q+ su2New = max 1e-8 $+ V.sum (V.zipWith (\ws uj -> 1.0/(ws + 1.0/su2) + uj*uj) wSumsV uNew)+ / fromIntegral q++ in (betaNew, su2New, uNew)++-- | Fit a non-Gaussian GLMM (random intercept) via Laplace approximation.+-- For Gaussian/Identity, prefer fitLMEDataFrame which uses exact EM.+fitGLMM+ :: Family -> LinkFn+ -> LA.Matrix Double+ -> LA.Vector Double+ -> V.Vector Int -- per-obs group index+ -> V.Vector Text -- sorted group labels+ -> V.Vector Int -- per-group sizes (unused; kept for API symmetry with fitLME)+ -> GLMMResult+fitGLMM family link x y idx labels _sizes =+ let n = LA.rows x+ p = LA.cols x+ q = V.length labels++ members = precompMembers idx q n++ -- Initialise: β₀ = g(ȳ_safe), rest 0; û = 0; σ²_u = half total variance+ yMean = LA.sumElements y / fromIntegral n+ ySafe = case family of+ Binomial -> max 1e-6 (min (1-1e-6) yMean)+ Poisson -> max 1e-6 yMean+ Gaussian -> yMean+ beta0 = LA.fromList (glmmFwdLink link ySafe : replicate (p - 1) 0.0)+ u0 = V.replicate q 0.0+ yDev = y - LA.konst yMean n+ su2_0 = max 1e-4 ((yDev `LA.dot` yDev) / fromIntegral n / 2)++ norm2V v = sqrt $ V.foldl' (\acc d -> acc + d*d) 0.0 v++ converge 0 st = st+ converge k st@(b, su, u') =+ let st'@(b', su', u'') = glmmStep family link x y idx members st+ in if LA.norm_2 (b' - b) < glmmTol+ && abs (su' - su) < glmmTol+ && norm2V (V.zipWith (-) u'' u') < glmmTol+ then st'+ else converge (k-1) st'++ (betaF, su2F, uF) = converge maxGLMMIter (beta0, su2_0, u0)++ -- Final conditional fitted values and statistics+ uScatterF = LA.fromList . V.toList $ zuScatter idx uF+ fittedLA = LA.cmap (glmmClampMu family . glmmInvLink link) (x LA.#> betaF + uScatterF)+ residLA = y - fittedLA+ ssTot = yDev `LA.dot` yDev+ ssRes = residLA `LA.dot` residLA+ r2 = if ssTot == 0 then 1.0 else 1.0 - ssRes / ssTot+ icc = iccApprox family su2F+ fitRes = FitResult (LA.asColumn betaF)+ (LA.asColumn fittedLA)+ (LA.asColumn residLA)+ (LA.fromList [r2])++ in GLMMResult fitRes su2F 1.0 uF labels icc++-- ---------------------------------------------------------------------------+-- General random effects (intercept + slope): vector Laplace for GLMM+-- ---------------------------------------------------------------------------++-- | Multivariate inner Newton-Raphson: find the conditional mode @b̂_j@ of one+-- group and return @(b̂_j, P_j)@ where @P_j = (Σ_i w_i z_i z_iᵀ + G⁻¹)⁻¹@ is+-- the Laplace posterior covariance at the mode.+--+-- Maximises @Q_j(b) = Σ_i log p(y_i | g⁻¹(η_i + z_iᵀ b)) − ½ bᵀ G⁻¹ b@.+-- Newton step solves @H δ = grad@ with+-- @grad = Σ_i s_i z_i − G⁻¹ b@, @H = Σ_i w_i z_i z_iᵀ + G⁻¹@.+nrOneGroupVec+ :: Family -> LinkFn+ -> LA.Matrix Double -- ^ G⁻¹ (r×r)+ -> [LA.Vector Double] -- ^ z_i rows for this group (each length r)+ -> [Double] -- ^ etaFixed_i = (X_i β)+ -> [Double] -- ^ y_i+ -> LA.Vector Double -- ^ initial b (length r)+ -> (LA.Vector Double, LA.Matrix Double)+nrOneGroupVec family link gInv zs etaFixed ys = go maxNRIter+ where+ clamp = glmmClampMu family+ gInvL = glmmInvLink link+ r = LA.rows gInv++ -- negative Hessian (= posterior precision) at b: Σ_i w_i z_i z_iᵀ + G⁻¹+ hessAt b =+ let etas = zipWith (\z ef -> ef + z `LA.dot` b) zs etaFixed+ mus = map (clamp . gInvL) etas+ ws = map (glmmWeight family link) mus+ in foldr (\(w, z) acc -> acc + LA.scale w (LA.outer z z)) gInv (zip ws zs)++ go 0 b = (b, LA.inv (hessAt b))+ go k b =+ let etas = zipWith (\z ef -> ef + z `LA.dot` b) zs etaFixed+ mus = map (clamp . gInvL) etas+ ss = zipWith (glmmScore family link) ys mus+ ws = map (glmmWeight family link) mus+ grad = foldr (\(s, z) acc -> acc + LA.scale s z) (LA.konst 0 r) (zip ss zs)+ - (gInv LA.#> b)+ hess = foldr (\(w, z) acc -> acc + LA.scale w (LA.outer z z)) gInv (zip ws zs)+ delta = LA.flatten (hess LA.<\> LA.asColumn grad)+ b' = b + delta+ in if LA.norm_2 delta < nrTol then (b', LA.inv hess) else go (k-1) b'++-- | Fit a non-Gaussian GLMM with __vector__ random effects via Laplace+-- approximation. Per group @j@: @g(E[y|b]) = X_j β + Z_j b_j@,+-- @b_j ~ N(0, G)@ (@G@ is @r×r@). Outer loop: multivariate NR for the modes+-- @b̂_j@ (with Laplace posterior cov @P_j@), one IRLS step for @β@ (random+-- effects as offset), and an EM update @G = (1/q) Σ_j (P_j + b̂_j b̂_jᵀ)@.+--+-- With @r = 1@ and an intercept-only @Z@ this matches 'fitGLMM'. Supports the+-- same families/links as 'fitGLMM' (Binomial/Logit, Poisson/Log).+fitGLMMGeneral+ :: Family -> LinkFn+ -> LA.Matrix Double -- ^ X (fixed-effect design, must include intercept)+ -> LA.Matrix Double -- ^ Z (random-effect design, @n × r@; rows align with X)+ -> LA.Vector Double -- ^ y+ -> V.Vector Int -- ^ per-observation group index (0-based)+ -> V.Vector Text -- ^ sorted group labels (length q)+ -> GLMMResultRE+fitGLMMGeneral family link x z y idx labels =+ let n = LA.rows x+ p = LA.cols x+ q = V.length labels+ r = LA.cols z+ members = precompMembers idx q n+ zRows = V.fromList (LA.toRows z)+ yV = V.fromList (LA.toList y)++ groupZs = V.fromList [ [ zRows V.! i | i <- members V.! j ] | j <- [0..q-1] ]+ groupYs = V.fromList [ [ yV V.! i | i <- members V.! j ] | j <- [0..q-1] ]++ clamp = glmmClampMu family+ gInvL = glmmInvLink link+ gD = glmmLinkDeriv link++ yMean = LA.sumElements y / fromIntegral n+ ySafe = case family of+ Binomial -> max 1e-6 (min (1-1e-6) yMean)+ Poisson -> max 1e-6 yMean+ Gaussian -> yMean+ beta0 = LA.fromList (glmmFwdLink link ySafe : replicate (p - 1) 0.0)+ b0 = V.replicate q (LA.konst 0 r)+ yDev = y - LA.konst yMean n+ su2_0 = max 1e-4 ((yDev `LA.dot` yDev) / fromIntegral n / 2)+ g0 = LA.scale su2_0 (LA.ident r)++ scatterZb bs =+ LA.fromList [ (zRows V.! i) `LA.dot` (bs V.! (idx V.! i)) | i <- [0..n-1] ]++ step (beta, gMat, bs) =+ let gInv = LA.inv gMat+ xBeta = x LA.#> beta+ etaFixedV = V.fromList (LA.toList xBeta)+ results = V.fromList+ [ nrOneGroupVec family link gInv (groupZs V.! j)+ [ etaFixedV V.! i | i <- members V.! j ]+ (groupYs V.! j)+ (bs V.! j)+ | j <- [0..q-1] ]+ bsNew = V.map fst results+ pjs = V.map snd results+ -- IRLS β with random offset Zb̂ held fixed+ zb = scatterZb bsNew+ etaF = xBeta + zb+ musV = V.map (clamp . gInvL) (V.fromList (LA.toList etaF))+ wsV = V.map (glmmWeight family link) musV+ xBetaV = V.fromList (LA.toList xBeta)+ zAdjV = V.zipWith3 (\yi mui xbi -> (yi - mui) * gD mui + xbi) yV musV xBetaV+ sqrtW = LA.diag (LA.fromList . V.toList $ V.map sqrt wsV)+ zAdj = LA.fromList (V.toList zAdjV)+ betaN = LA.flatten $ (sqrtW LA.<> x) LA.<\> LA.asColumn (sqrtW LA.#> zAdj)+ -- EM update G = (1/q) Σ (P_j + b̂_j b̂_jᵀ)+ gAcc = V.foldl' (\acc (pj, bj) -> acc + pj + LA.outer bj bj)+ (LA.konst 0 (r, r)) (V.zip pjs bsNew)+ gN = LA.scale (1 / fromIntegral q) gAcc+ in (betaN, gN, bsNew)++ bsDiff a b = V.sum (V.zipWith (\u v -> LA.norm_2 (u - v)) a b)+ converge 0 st = st+ converge k st@(beta, gM, bs) =+ let st'@(beta', gM', bs') = step st+ in if LA.norm_2 (beta' - beta) < glmmTol+ && LA.norm_2 (LA.flatten (gM' - gM)) < glmmTol+ && bsDiff bs' bs < glmmTol+ then st'+ else converge (k-1) st'++ (betaF, gF, bsF) = converge maxGLMMIter (beta0, g0, b0)++ zbF = scatterZb bsF+ fittedV = LA.cmap (clamp . gInvL) (x LA.#> betaF + zbF)+ residV = y - fittedV+ ssTot = yDev `LA.dot` yDev+ ssRes = residV `LA.dot` residV+ r2 = if ssTot == 0 then 1.0 else 1.0 - ssRes / ssTot+ fitRes = FitResult (LA.asColumn betaF)+ (LA.asColumn fittedV)+ (LA.asColumn residV)+ (LA.fromList [r2])+ blupMat = LA.fromRows (V.toList bsF)++ in GLMMResultRE fitRes gF 1.0 blupMat labels++-- ---------------------------------------------------------------------------+-- DataFrame-level API+-- ---------------------------------------------------------------------------++-- | Fit a random-intercept LME from a DataFrame (Gaussian, exact EM).+fitLMEDataFrame+ :: [(Text, Int)] -- ^ x column specs+ -> Text -- ^ grouping column (text/categorical)+ -> Text -- ^ response column+ -> DXD.DataFrame+ -> Maybe GLMMResult+fitLMEDataFrame colDegs groupCol yCol df = do+ xVecs <- mapM (\(col, _) -> getDoubleVec col df) colDegs+ yVec <- getDoubleVec yCol df+ gVec <- getTextVec groupCol df+ let degrees = map snd colDegs+ dm = multiPolyDesignMatrix (zip xVecs degrees)+ y = LA.fromList (V.toList yVec)+ (labels, idx, sizes) = buildGroups gVec+ return (fitLME dm y idx labels sizes)++-- | Fit a non-Gaussian GLMM from a DataFrame (Laplace approximation).+-- Supports Binomial/Logit and Poisson/Log; for Gaussian/Identity prefer fitLMEDataFrame.+fitGLMMDataFrame+ :: Family -> LinkFn+ -> [(Text, Int)] -- ^ x column specs+ -> Text -- ^ grouping column (text/categorical)+ -> Text -- ^ response column+ -> DXD.DataFrame+ -> Maybe GLMMResult+fitGLMMDataFrame family link colDegs groupCol yCol df = do+ xVecs <- mapM (\(col, _) -> getDoubleVec col df) colDegs+ yVec <- getDoubleVec yCol df+ gVec <- getTextVec groupCol df+ let degrees = map snd colDegs+ dm = multiPolyDesignMatrix (zip xVecs degrees)+ y = LA.fromList (V.toList yVec)+ (labels, idx, sizes) = buildGroups gVec+ return (fitGLMM family link dm y idx labels sizes)++-- ---------------------------------------------------------------------------+-- Multi-output GLMM (per-column EM/Laplace; grouping shared across columns)+-- ---------------------------------------------------------------------------++-- | Multi-output GLMM/LME fit result.+data GLMMResultMulti = GLMMResultMulti+ { glmmFits :: [GLMMResult] -- ^ Per-column fit results.+ , glmmGrpsM :: V.Vector Text -- ^ Sorted group labels (shared across columns).+ } deriving (Show)++-- | Multi-output Gaussian LME. @Y@ has shape @n × q@; 'fitLME' is run+-- independently on each column.+fitLMEMulti :: LA.Matrix Double -> LA.Matrix Double+ -> V.Vector Int -> V.Vector Text -> V.Vector Int+ -> GLMMResultMulti+fitLMEMulti x y idx labels sizes =+ let q = LA.cols y+ yCol j = LA.flatten (y LA.¿ [j])+ fits = [fitLME x (yCol j) idx labels sizes | j <- [0 .. q - 1]]+ in GLMMResultMulti fits labels++-- | Multi-output non-Gaussian GLMM. @Y@ has shape @n × q@; 'fitGLMM' is+-- run independently on each column.+fitGLMMMulti :: Family -> LinkFn+ -> LA.Matrix Double -> LA.Matrix Double+ -> V.Vector Int -> V.Vector Text -> V.Vector Int+ -> GLMMResultMulti+fitGLMMMulti family link x y idx labels sizes =+ let q = LA.cols y+ yCol j = LA.flatten (y LA.¿ [j])+ fits = [fitGLMM family link x (yCol j) idx labels sizes+ | j <- [0 .. q - 1]]+ in GLMMResultMulti fits labels++-- ---------------------------------------------------------------------------+-- Standard errors (request/100)+-- ---------------------------------------------------------------------------++-- | Standard errors of the fixed-effect coefficients @β@.+--+-- For LME (Gaussian, Identity link) this is /exact/: it inverts+-- @Xᵀ V⁻¹ X@ where @V = σ² I + σ²_u Z Zᵀ@ is the marginal covariance+-- under the random-intercept model. The block structure of @V@ is+-- exploited so this stays @O(n p² + q p²)@ instead of forming a+-- dense @n × n@ matrix:+--+-- > Xᵀ V⁻¹ X = (1/σ²) Xᵀ X − Σ_j (α_j / σ²) s_j s_jᵀ+-- > α_j = σ²_u / (σ² + n_j σ²_u)+-- > s_j = Σ_{i ∈ group j} x_i (column sums of X within group j)+--+-- For non-Gaussian families this returns a Gaussian-approximation+-- (treats @σ² = 1@) — adequate for /relative/ ordering of coefficients+-- but absolute values are off; matching lme4-style non-Gaussian SE+-- requires the converged IRLS weights which are not currently exposed+-- by 'fitGLMM'.+glmmFixedSE+ :: LA.Matrix Double -- ^ Design matrix @X@ (n × p, intercept inclusive).+ -> V.Vector Int -- ^ Group index per observation (length n; same as+ -- the @idx@ produced by @buildGroups@).+ -> GLMMResult+ -> LA.Vector Double -- ^ Length @p@; coefficient SEs in column order.+glmmFixedSE x groupIdx res =+ let n = LA.rows x+ p = LA.cols x+ sig2u = glmmRandVar res+ sig2RAW = glmmResidVar res+ sig2 = if sig2RAW > 0 then sig2RAW else 1.0 -- non-Gaussian fallback+ q = V.length (glmmGroups res)++ -- per-group n_j+ nj :: Map.Map Int Int+ nj = V.foldl' (\acc j -> Map.insertWith (+) j 1 acc) Map.empty groupIdx++ -- per-group column sum s_j = Σ_{i ∈ group j} x_i (length p)+ groupSum :: Map.Map Int (LA.Vector Double)+ groupSum =+ V.foldl' (\acc i ->+ let j = groupIdx V.! i+ xi = LA.flatten (x LA.? [i])+ in Map.insertWith (+) j xi acc)+ Map.empty+ (V.enumFromN 0 n)++ xtxFull = LA.tr x LA.<> x++ correction :: LA.Matrix Double+ correction =+ Map.foldlWithKey'+ (\acc j s ->+ let nj_j = Map.findWithDefault 0 j nj+ alpha = sig2u / (sig2 + fromIntegral nj_j * sig2u)+ in acc + LA.scale alpha (LA.outer s s))+ (LA.konst 0 (p, p))+ groupSum++ xvtinvX = LA.scale (1 / sig2) (xtxFull - correction)+ cov = LA.inv xvtinvX+ _ = q -- kept to make q's role explicit in the docstring+ in LA.fromList [ sqrt (max 0 (LA.atIndex cov (i, i))) | i <- [0 .. p - 1] ]++-- | Posterior standard errors of the BLUPs @û_j@ under the+-- random-intercept model:+--+-- > Var(u_j | data) = (1 / σ²_u + n_j / σ²)⁻¹+--+-- (For non-Gaussian families this uses @σ² = 1@; same caveat as+-- 'glmmFixedSE'.) Length matches 'glmmGroups'.+glmmBLUPSE :: V.Vector Int -> GLMMResult -> V.Vector Double+glmmBLUPSE groupIdx res =+ let q = V.length (glmmGroups res)+ sig2u = glmmRandVar res+ sig2RAW = glmmResidVar res+ sig2 = if sig2RAW > 0 then sig2RAW else 1.0+ njMap = V.foldl' (\acc j -> Map.insertWith (+) j 1 acc)+ Map.empty groupIdx+ ng j = Map.findWithDefault 0 j njMap+ in V.generate q (\j ->+ let nDouble = fromIntegral (ng j) :: Double+ varInv = 1.0 / sig2u + nDouble / sig2+ in sqrt (1.0 / varInv))
+ src/Hanalyze/Model/GP.hs view
@@ -0,0 +1,938 @@+{-# LANGUAGE StrictData #-}+{-# LANGUAGE OverloadedStrings #-}+-- |+-- Module : Hanalyze.Model.GP+-- Description : ガウス過程回帰 (Gaussian-process regression)+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- [日本語]: ガウス過程回帰 (Gaussian-process regression)。+--+-- カーネルを選び、 訓練データに fit して、 任意のテスト点での事後予測を得る。+-- ハイパーパラメータは対数周辺尤度の最大化で自動調整できる。+--+-- @+-- import Hanalyze.Model.GP+--+-- -- 訓練データ+-- let xs = [0, 0.5 .. 5]+-- ys = map (\x -> sin x + 0.1 * noise) xs+--+-- -- ハイパーパラメータをデータから初期化し最適化+-- let p0 = initParamsFromData xs ys+-- opt = optimizeGP RBF xs ys p0+-- res = fitGP (GPModel RBF opt) xs ys testXs+--+-- -- gpMean res, gpLower res, gpUpper res で結果を取得+-- @+--+-- [English]: Gaussian-process regression.+--+-- Pick a kernel, fit it to training data and obtain the posterior+-- predictive at arbitrary test points. Hyperparameters can be tuned+-- automatically by maximizing the log marginal likelihood.+--+-- @+-- import Hanalyze.Model.GP+--+-- -- training data+-- let xs = [0, 0.5 .. 5]+-- ys = map (\x -> sin x + 0.1 * noise) xs+--+-- -- initialize the hyperparameters from the data and optimize them+-- let p0 = initParamsFromData xs ys+-- opt = optimizeGP RBF xs ys p0+-- res = fitGP (GPModel RBF opt) xs ys testXs+--+-- -- retrieve the results via gpMean res, gpLower res, gpUpper res+-- @+module Hanalyze.Model.GP+ ( -- * カーネル型 (re-export from "Hanalyze.Model.Kernel")+ Kernel (..)+ , kernelName+ , KernelParams (..)+ , defaultKernelParams+ -- * Hyperparameters+ , GPParams (..)+ , defaultGPParams+ , gpKernelParams+ , initParamsFromData+ , initParamsFromDataMV+ -- * Model and result+ , GPModel (..)+ , GPResult (..)+ -- * Kernel computation+ , kernelFn+ , kEvalMV+ , buildKernelMatrix+ -- * Inference+ , logMarginalLikelihood+ , fitGP+ , fitGPMulti+ , optimizeGP+ , gramLOOCV+ , autoCVHyperGP+ , autoCVHyperGPMV+ -- * Data for interactive prediction+ , GPPredData (..)+ , gpPredData+ -- * Multi-input (primary API; X is @n × p@, Y is @n × q@)+ , GPResultMV (..)+ , buildKernelMatrixMV+ , noiseKernelMV+ , logMarginalLikelihoodMV+ , fitGPMV+ , fitGPMVMulti+ , optimizeGPMV+ , optimizeGPMVCached+ ) where++import qualified Numeric.LinearAlgebra as LA+import qualified Hanalyze.Optim.LBFGS as LBFGS+import qualified Hanalyze.Optim.Common as OC+import qualified Hanalyze.Stat.KernelDist as KD+import qualified Hanalyze.Stat.Cholesky as Chol+import qualified Data.Vector.Storable as VS+import qualified Data.Vector.Storable.Mutable as VSM+import Control.Monad.ST (runST)+import System.IO.Unsafe (unsafePerformIO)+-- 共有カーネル語彙は 'Model.Kernel' (Phase 75.18 で分離)。 GP は後方互換のため+-- 'Kernel'/'KernelParams'/評価関数を re-export する。+import Hanalyze.Model.Kernel+ ( Kernel (..), kernelName, KernelParams (..), defaultKernelParams+ , kernelFn, buildKernelMatrix, applyKernel, kernelOfParams+ , ardScaleXY, buildKernelMatrixMV, kEvalMV )++-- ---------------------------------------------------------------------------+-- Types+-- ---------------------------------------------------------------------------+--+-- NB: 'Kernel' / 'kernelName' / 'KernelParams' と評価関数群は Phase 75.18 で+-- 'Hanalyze.Model.Kernel' へ分離。 GP は後方互換のため re-export する+-- (上の import 参照)。++-- | [日本語]: GP hyperparameters (= 'KernelParams' + 観測ノイズ σ_n²)。+--+-- カーネル系フィールド (ℓ / σ_f² / period / ARD) は 'gpKernelParams' で+-- 'KernelParams' へ射影でき、 カーネル評価関数 ('kernelFn' / 'kEvalMV' /+-- 'buildKernelMatrix' 等) はその 'KernelParams' を取る。+--+-- [English]: GP hyperparameters (= 'KernelParams' + the observation noise+-- σ_n²).+--+-- The kernel-related fields (ℓ \/ σ_f² \/ period \/ ARD) can be projected onto+-- a 'KernelParams' with 'gpKernelParams', and the kernel evaluation functions+-- ('kernelFn' \/ 'kEvalMV' \/ 'buildKernelMatrix' etc.) take that+-- 'KernelParams'.+data GPParams = GPParams+ { gpLengthScale :: Double+ -- ^ Isotropic length scale @ℓ@; larger means smoother. Used unless+ -- 'gpLengthScales' is 'Just' (= ARD), in which case the per-dim+ -- vector overrides this for multi-input kernel evaluation.+ , gpSignalVar :: Double+ -- ^ Signal variance @σ_f²@; the variability of the function values.+ , gpNoiseVar :: Double+ -- ^ Observation noise variance @σ_n²@; near 0 interpolates, larger+ -- smooths.+ , gpPeriod :: Double+ -- ^ Period @p@ (only used by the @Periodic@ kernel).+ , gpLengthScales :: Maybe (LA.Vector Double)+ -- ^ Per-dim length scales for ARD (Automatic Relevance+ -- Determination). When 'Just' v, the multi-input kernel uses+ -- @D_ARD[i,j] = Σ_d (X[i,d] − X'[j,d])² / ℓ_d²@ instead of the+ -- isotropic distance / ℓ². Has no effect on the 1D 'kernelFn' /+ -- 'fitGP' path. 'Nothing' = isotropic (default).+ } deriving (Show)++-- | Default hyperparameters: @ℓ = σ_f² = p = 1@, @σ_n² = 0.1@.+defaultGPParams :: GPParams+defaultGPParams = GPParams 1.0 1.0 0.1 1.0 Nothing++-- | [日本語]: 'GPParams' のカーネル系ハイパーパラメータを 'KernelParams' へ+-- 射影する (観測ノイズ σ_n² は落とす)。 カーネル評価関数へ+-- 渡す際に使う。+--+-- [English]: Project the kernel hyperparameters of a 'GPParams' onto a+-- 'KernelParams' (drops the observation noise σ_n²). Used when passing them+-- to the kernel evaluation functions.+gpKernelParams :: GPParams -> KernelParams+gpKernelParams p = KernelParams+ { kpLengthScale = gpLengthScale p+ , kpSignalVar = gpSignalVar p+ , kpPeriod = gpPeriod p+ , kpLengthScales = gpLengthScales p+ }++-- | Build a sensible initial 'GPParams' from data statistics, suitable+-- as a starting point for optimization.+initParamsFromData :: [Double] -> [Double] -> GPParams+initParamsFromData xs ys = GPParams+ { gpLengthScale = max 0.01 ((xMax - xMin) / 4)+ , gpSignalVar = max 0.01 yVar+ , gpNoiseVar = max 1e-4 (yVar * 0.05)+ , gpPeriod = max 0.01 (xMax - xMin)+ , gpLengthScales = Nothing+ }+ where+ xMin = minimum xs+ xMax = maximum xs+ yMean = sum ys / fromIntegral (length ys)+ yVar = sum (map (\y -> (y - yMean) ^ (2 :: Int)) ys) / fromIntegral (length ys)++-- | Multi-input variant of 'initParamsFromData'. Computes the length+-- scale from the /average/ per-dimension range of @X@ rather than+-- collapsing the @n × p@ matrix into a flat list (which the previous+-- @MultiGP@ call site did via @concat (toLists trainX)@ — yielding+-- nonsensical @xMin/xMax@ statistics, a poor length-scale init, and+-- in turn slow LBFGS convergence).+initParamsFromDataMV :: LA.Matrix Double -> LA.Vector Double -> GPParams+initParamsFromDataMV trainX y =+ let p = LA.cols trainX+ cols = LA.toColumns trainX -- p column vectors+ ranges = [ LA.maxElement c - LA.minElement c | c <- cols ]+ avgRng = if null ranges then 1.0+ else sum ranges / fromIntegral (length ranges)+ ys = LA.toList y+ yMean = LA.sumElements y / fromIntegral (LA.size y)+ yVar = sum (map (\v -> (v - yMean) ^ (2 :: Int)) ys)+ / fromIntegral (LA.size y)+ _ = p+ in GPParams+ { gpLengthScale = max 0.01 (avgRng / 4)+ , gpSignalVar = max 0.01 yVar+ , gpNoiseVar = max 1e-4 (yVar * 0.05)+ , gpPeriod = max 0.01 avgRng+ , gpLengthScales = Nothing+ }++-- | A GP model: a kernel paired with its hyperparameters.+data GPModel = GPModel+ { gpKernel :: Kernel+ , gpParams :: GPParams+ } deriving (Show)++-- | GP posterior-predictive result.+data GPResult = GPResult+ { gpTestX :: [Double] -- ^ Test points @x_*@.+ , gpMean :: [Double] -- ^ Posterior mean @μ(x_*)@.+ , gpVar :: [Double] -- ^ Posterior variance @σ²(x_*)@.+ , gpLower :: [Double] -- ^ @mean − 2σ@ (≈ 95 % credible-interval lower).+ , gpUpper :: [Double] -- ^ @mean + 2σ@ (≈ 95 % credible-interval upper).+ } deriving (Show)++-- ---------------------------------------------------------------------------+-- Inference+-- ---------------------------------------------------------------------------++-- ノイズ付きカーネル行列 K_y = K(X,X) + σ_n² I を構築する(最小ジッター付き)。+noiseKernel :: Kernel -> GPParams -> [Double] -> LA.Matrix Double+noiseKernel ker p xs =+ let n = length xs+ k = buildKernelMatrix ker (gpKernelParams p) xs xs+ jitter = max (gpNoiseVar p) 1e-6+ in k `LA.add` LA.scale jitter (LA.ident n)++-- | Log marginal likelihood @log p(y | X, θ)@. Used as the objective+-- when optimizing GP hyperparameters.+--+-- @log p = −½ yᵀ Ky⁻¹ y − ½ log|Ky| − n/2 log(2π)@.+--+-- When the parameters are pathological (e.g. very small length scales)+-- and Cholesky fails, returns the penalty value @-10³⁰@ so the+-- optimizer steers away from that region.+logMarginalLikelihood :: [Double] -> [Double] -> Kernel -> GPParams -> Double+logMarginalLikelihood trainX trainY ker params =+ let n = length trainX+ ky = noiseKernel ker params trainX+ y = LA.fromList trainY+ mR = case Chol.cholFactor ky of+ Just r -> Just (r, ky)+ Nothing ->+ -- jitter を追加して再試行+ let kyJ = ky `LA.add` LA.scale 1e-4 (LA.ident n)+ in case Chol.cholFactor kyJ of+ Just r -> Just (r, kyJ)+ Nothing -> Nothing+ in case mR of+ Nothing -> -1e30+ Just (r, _kyUsed) ->+ let logDet = 2 * sum (map log (LA.toList (LA.takeDiag r)))+ -- Reuse the already-computed Cholesky factor (avoids a+ -- second factorization in the inner GP HP loop).+ alpha = LA.flatten+ (Chol.cholSolveWithFactor r (LA.asColumn y))+ dataFit = LA.dot y alpha+ in -0.5 * dataFit - 0.5 * logDet - fromIntegral n / 2 * log (2 * pi)++-- | [日本語]: @testX@ における単出力 GP の事後予測。+-- 多出力 'fitGPMulti' に y を 1 列行列化して委譲、列 0 を取り出す。+--+-- 事後平均: μ_* = K_*ᵀ Ky⁻¹ y+-- 事後分散: σ²_i = k(x*_i, x*_i) − K_*[i] Ky⁻¹ K_*[i]ᵀ+--+-- [English]: Single-output GP posterior prediction at @testX@.+-- Delegates to the multi-output 'fitGPMulti' by turning y into a+-- single-column matrix, and extracts column 0.+--+-- Posterior mean: μ_* = K_*ᵀ Ky⁻¹ y+-- Posterior variance: σ²_i = k(x*_i, x*_i) − K_*[i] Ky⁻¹ K_*[i]ᵀ+fitGP :: GPModel -> [Double] -> [Double] -> [Double] -> GPResult+fitGP model trainX trainY testX =+ let yMat = LA.asColumn (LA.fromList trainY)+ (meanMat, varList) = fitGPMulti model trainX yMat testX+ mu = LA.toList (LA.flatten (meanMat LA.¿ [0]))+ stdList = map sqrt varList+ in GPResult+ { gpTestX = testX+ , gpMean = mu+ , gpVar = varList+ , gpLower = zipWith (\m s -> m - 2 * s) mu stdList+ , gpUpper = zipWith (\m s -> m + 2 * s) mu stdList+ }++-- | [日本語]: 多出力 GP の事後予測。 @Y@ は @n × q@ (出力タスクごとに 1 列) で、+-- 単一のカーネルと+-- ハイパーパラメータを共有する (Cholesky / Ky⁻¹ も共有)。+--+-- 戻り値: (事後平均行列 m × q, 事後分散ベクトル 長さ m)。+-- 分散は y に依らないため q 出力で共通。+--+-- [English]: Multi-output GP posterior prediction. @Y@ has shape @n × q@ (one+-- column per output task) and shares a single kernel and one set of+-- hyperparameters (the Cholesky factor \/ Ky⁻¹ are shared as well).+--+-- Returns: (the m × q posterior mean matrix, the length-m posterior variance+-- vector). The variance does not depend on y, so it is common to all q+-- outputs.+fitGPMulti :: GPModel -> [Double] -> LA.Matrix Double -> [Double]+ -> (LA.Matrix Double, [Double])+fitGPMulti model trainX trainY testX =+ let ker = gpKernel model+ params = gpParams model+ ky = noiseKernel ker params trainX+ kStar = buildKernelMatrix ker (gpKernelParams params) testX trainX -- (m × n)+ -- α = Ky⁻¹ Y via SPD Cholesky (n × q)+ alpha = Chol.cholSolveJitter ky trainY+ meanMt = kStar LA.<> alpha -- (m × q)+ -- v = Ky⁻¹ K_*ᵀ via the same Cholesky factor (n × m).+ -- Then var_i = k(x*_i, x*_i) − K_*[i,:] · v[:,i].+ v = Chol.cholSolveJitter ky (LA.tr kStar)+ diagKss = [kernelFn ker (gpKernelParams params) x x | x <- testX]+ -- F1: vectorise diag(kStar · v).+ kStarDotV = LA.toList (KD.diagAB kStar v)+ varList = zipWith (\d kv -> max 0 (d - kv)) diagKss kStarDotV+ in (meanMt, varList)++-- ---------------------------------------------------------------------------+-- Hyperparameter optimisation+-- ---------------------------------------------------------------------------++-- | Optimize GP hyperparameters by maximizing the log marginal likelihood.+--+-- Operates in log-space on @(ℓ, σ_f², σ_n²)@ using L-BFGS (numerical+-- central-difference gradients, no user-provided gradient required).+--+-- Typically 5-10× faster than the older @Hanalyze.Optim.GradAscent@ + numeric+-- gradient path, and less sensitive to the initial point.+-- Internally uses 'System.IO.Unsafe.unsafePerformIO', but L-BFGS is+-- deterministic so the result is referentially transparent.+optimizeGP :: Kernel -> [Double] -> [Double] -> GPParams -> GPParams+optimizeGP ker trainX trainY p0 =+ let u0 = [log (gpLengthScale p0), log (gpSignalVar p0), log (gpNoiseVar p0)]+ -- L-BFGS は最小化なので、log-mlik を最大化したいときは Maximize 指定+ cfg = LBFGS.defaultLBFGSConfig+ { LBFGS.lbDir = OC.Maximize+ , LBFGS.lbStop = OC.defaultStopCriteria+ { OC.stMaxIter = 200, OC.stTolFun = 1e-8 }+ }+ result = unsafePerformIO $ LBFGS.runLBFGSNumeric cfg obj u0+ uOpt = OC.orBest result+ in p0+ { gpLengthScale = exp (uOpt !! 0)+ , gpSignalVar = exp (uOpt !! 1)+ , gpNoiseVar = exp (uOpt !! 2)+ }+ where+ toParams u = p0+ { gpLengthScale = exp (u !! 0)+ , gpSignalVar = exp (u !! 1)+ , gpNoiseVar = exp (u !! 2)+ }+ obj u = logMarginalLikelihood trainX trainY ker (toParams u)++-- ---------------------------------------------------------------------------+-- LOOCV hyperparameter selection (exact / Gram path) — Phase 70.5 項目 E+-- ---------------------------------------------------------------------------++-- | Leave-one-out CV (PRESS) for exact kernel-ridge / GP-mean prediction+-- from a /noiseless/ Gram matrix @K@. Closed form+-- @PRESS = (1/n) Σ ((yᵢ − ŷᵢ)/(1 − Hᵢᵢ))²@ with @H = K (K + λI)⁻¹@ and+-- @ŷ = H y@ (no @n@-fold refit). This is the Gram-space analogue of+-- 'Hanalyze.Model.RFF.loocvFromPhi' (identical PRESS algebra, but+-- in the @n@-dim Gram space instead of the @D@-dim RFF feature space).+-- KRR ≡ GP posterior mean with @λ = σ_n²@, so the same routine selects+-- @λ@ for both the @Ridge@ and @Gp@ quadrants of the unified @gp@ spec.+gramLOOCV :: LA.Matrix Double -- ^ Noiseless Gram matrix @K@ (@n × n@).+ -> LA.Vector Double -- ^ Targets @y@ (length @n@).+ -> Double -- ^ Ridge penalty @λ@ (= @σ_n²@).+ -> Double+gramLOOCV k y lam =+ let n = LA.rows k+ regK = addToDiag lam k -- K + λI (SPD)+ -- H = K (K+λI)⁻¹ = (regK⁻¹ K)ᵀ (K, regK symmetric). Solve once.+ h = LA.tr (regK LA.<\> k)+ yhat = h LA.#> y+ hDiag = LA.takeDiag h+ oneMinusH = LA.cmap (\hh -> max 1e-12 (1 - hh)) hDiag+ resid = y - yhat+ ratios = zipWith (/) (LA.toList resid) (LA.toList oneMinusH)+ in sum [ r * r | r <- ratios ] / fromIntegral (max 1 n)++-- | Pick GP/KRR hyperparameters by minimizing leave-one-out CV (PRESS)+-- over a log-spaced @(ℓ, λ)@ grid. @σ_f@ is fixed at @std(y)@ (mirroring+-- 'Hanalyze.Model.RFF.gridSearchLOOCVRBFMV', where @σ_f@ and @λ@+-- are degenerate and @λ@ absorbs the scale). Returns 'GPParams' with the+-- selected @ℓ*@, @σ_f² = std(y)²@ and @σ_n² = λ*@ (KRR ≡ GP mean with+-- @λ = σ_n²@). Used by the @AutoCV@ @HyperStrategy@ for the exact+-- (@Gp@/@Ridge@) quadrants.+autoCVHyperGP :: Kernel -> [Double] -> [Double] -> GPParams+autoCVHyperGP ker xs ys =+ let p0 = initParamsFromData xs ys+ yStd = max 1e-9 (sqrt (varOfList ys))+ ell0 = gpLengthScale p0+ ellGrid = logSpaceList (ell0 * 0.1) (ell0 * 10) 10+ lamGrid = logSpaceList (yStd * 1e-6) (yStd * 10) 20+ yV = LA.fromList ys+ score ell lam =+ let pk = p0 { gpLengthScale = ell, gpSignalVar = yStd * yStd }+ k = buildKernelMatrix ker (gpKernelParams pk) xs xs+ in gramLOOCV k yV lam+ cands = [ (ell, lam, score ell lam) | ell <- ellGrid, lam <- lamGrid ]+ (bEll, bLam, _) =+ foldr1 (\a@(_,_,sa) b@(_,_,sb) -> if sa <= sb then a else b) cands+ in p0 { gpLengthScale = bEll, gpSignalVar = yStd * yStd, gpNoiseVar = bLam }++-- | Multi-input analogue of 'autoCVHyperGP'. Same log-spaced @(ℓ, λ)@+-- Gram-LOOCV search but builds the kernel from an @n × p@ training+-- matrix via 'buildKernelMatrixMV' (isotropic; ℓ shared across inputs).+autoCVHyperGPMV :: Kernel -> LA.Matrix Double -> LA.Vector Double -> GPParams+autoCVHyperGPMV ker trainX y =+ let p0 = initParamsFromDataMV trainX y+ yStd = max 1e-9 (sqrt (varOfList (LA.toList y)))+ ell0 = gpLengthScale p0+ ellGrid = logSpaceList (ell0 * 0.1) (ell0 * 10) 8+ lamGrid = logSpaceList (yStd * 1e-6) (yStd * 10) 16+ score ell lam =+ let pk = p0 { gpLengthScale = ell, gpSignalVar = yStd * yStd }+ k = buildKernelMatrixMV ker (gpKernelParams pk) trainX trainX+ in gramLOOCV k y lam+ cands = [ (ell, lam, score ell lam) | ell <- ellGrid, lam <- lamGrid ]+ (bEll, bLam, _) =+ foldr1 (\a@(_,_,sa) b@(_,_,sb) -> if sa <= sb then a else b) cands+ in p0 { gpLengthScale = bEll, gpSignalVar = yStd * yStd, gpNoiseVar = bLam }++-- | Population variance of a list (LOOCV σ_f init).+varOfList :: [Double] -> Double+varOfList zs =+ let n = fromIntegral (length zs)+ m = sum zs / n+ in if n <= 0 then 0 else sum [ (z - m) ^ (2 :: Int) | z <- zs ] / n++-- | @n@ points log-spaced in @[lo, hi]@ (inclusive). @lo,hi > 0@.+logSpaceList :: Double -> Double -> Int -> [Double]+logSpaceList lo hi n+ | n <= 1 = [lo]+ | otherwise = [ exp (logLo + (logHi - logLo) * fromIntegral i / fromIntegral (n - 1))+ | i <- [0 .. n - 1] ]+ where logLo = log lo+ logHi = log hi++-- ---------------------------------------------------------------------------+-- Interactive prediction data (for Hanalyze.Viz.GPReport)+-- ---------------------------------------------------------------------------++-- | [日本語]: JavaScript 対話予測に必要な内部データ。+-- Ky⁻¹ と α = Ky⁻¹ y を事前に計算して保持する。+--+-- [English]: The internal data required for interactive prediction in+-- JavaScript. Pre-computes and stores Ky⁻¹ and α = Ky⁻¹ y.+data GPPredData = GPPredData+ { pdTrainX :: [Double] -- ^ [日本語]: 訓練点 X [English]: The training points X.+ , pdAlpha :: [Double] -- ^ [日本語]: α = Ky⁻¹ y (長さ n) [English]: α = Ky⁻¹ y (length n).+ , pdKyInv :: [[Double]] -- ^ [日本語]: Ky⁻¹ を行リストで表現 (n × n) [English]: Ky⁻¹ represented as a list of rows (n × n).+ } deriving (Show)++-- | [日本語]: 訓練データから GPPredData を計算する。+-- [English]: Computes a GPPredData from the training data.+gpPredData :: GPModel -> [Double] -> [Double] -> GPPredData+gpPredData model trainX trainY =+ let ker = gpKernel model+ params = gpParams model+ n = length trainX+ k = buildKernelMatrix ker (gpKernelParams params) trainX trainX+ jitter = max (gpNoiseVar params) 1e-6+ ky = addToDiag jitter k+ -- SPD: solve via Cholesky rather than 'LA.inv'. Equivalent to+ -- 'kyInv = Ky⁻¹' (used to project the JS-side prediction+ -- formula); the explicit inverse is fine here because @n@ is+ -- typically small for the interactive viewer and the inverse is+ -- consumed downstream. Cholesky is more accurate than LU.+ kyInv = Chol.cholSolveJitter ky (LA.ident n)+ alpha = LA.toList (kyInv LA.#> LA.fromList trainY)+ in GPPredData trainX alpha (map LA.toList (LA.toRows kyInv))++-- ---------------------------------------------------------------------------+-- Multi-input (multivariate X) API+--+-- The kernel of every supported family ('RBF', 'Matern52', 'Periodic') is a+-- function of the Euclidean distance @r = ‖x − x'‖@, so the multi-input+-- version reduces to building the @n × n@ pairwise distance matrix once+-- (via 'Hanalyze.Stat.KernelDist.pairwiseSqDist') and applying the kernel function+-- element-wise via 'LA.cmap'.+--+-- A single shared length scale @ℓ@ is used across every input dimension.+-- For axis-specific length scales, scale columns of @X@ by @1 / ℓ_d@+-- before calling these functions.+-- ---------------------------------------------------------------------------++-- | Multi-input GP posterior result. Mirrors 'GPResult' but stores the+-- @m × p@ test-point matrix instead of a 1D list.+data GPResultMV = GPResultMV+ { gpmvTestX :: LA.Matrix Double -- ^ Test points (@m × p@).+ , gpmvMean :: LA.Vector Double -- ^ Posterior mean (length @m@).+ , gpmvVar :: LA.Vector Double -- ^ Posterior variance (length @m@).+ , gpmvLower :: LA.Vector Double -- ^ @mean − 2σ@.+ , gpmvUpper :: LA.Vector Double -- ^ @mean + 2σ@.+ } deriving (Show)++-- | Add a scalar @c@ to the diagonal of a square matrix in one pass.+--+-- Replaces the @M + c·I@ pattern (which allocates a fresh @n × n@+-- identity scaled by @c@). With @runST@ + flat-index update, this+-- is one allocation of the result and an in-place fill — significant+-- in 'noiseKernelMV', which is on every log-marginal-likelihood+-- evaluation.+addToDiag :: Double -> LA.Matrix Double -> LA.Matrix Double+addToDiag c m =+ let n = LA.rows m+ flat = LA.flatten m+ out = runST $ do+ v <- VSM.new (n * n)+ let go i+ | i >= n * n = pure ()+ | otherwise = do+ VSM.unsafeWrite v i (flat `VS.unsafeIndex` i)+ go (i + 1)+ go 0+ let goDiag i+ | i >= n = pure ()+ | otherwise = do+ let !idx = i * n + i+ d <- VSM.unsafeRead v idx+ VSM.unsafeWrite v idx (d + c)+ goDiag (i + 1)+ goDiag 0+ VS.unsafeFreeze v+ in LA.reshape n out++-- | Build the noise-augmented kernel matrix @K + jitter·I@ in a single+-- pass over the squared-distance matrix.+--+-- Replaces the previous @applyKernel d2 |> addToDiag jitter@ pipeline,+-- which allocated /two/ @n × n@ Storable vectors per evaluation: one+-- for the kernel-applied output, one for the diagonal-augmented copy.+-- This fused version emits a single @n²@ allocation and writes each+-- cell exactly once, branching on @i == j@ to fold the jitter into the+-- diagonal write. A @noiseKernelMVCached@ call profile fraction was+-- 35.3% of @optimizeGPMV@; halving its allocation footprint translates+-- to a measurable wall-time reduction in the LBFGS hot loop.+mkNoiseKernelFromD2+ :: Kernel -> KernelParams -> Double -> LA.Matrix Double -> LA.Matrix Double+mkNoiseKernelFromD2 ker p jitter d2 =+ let n = LA.rows d2+ flatD = LA.flatten d2+ kFn = kernelOfParams ker p+ out = runST $ do+ v <- VSM.new (n * n)+ let go i j+ | i >= n = pure ()+ | j >= n = go (i + 1) 0+ | otherwise = do+ let !idx = i * n + j+ !s = flatD `VS.unsafeIndex` idx+ !kij = kFn s+ !val = if i == j then kij + jitter else kij+ VSM.unsafeWrite v idx val+ go i (j + 1)+ go 0 0+ VS.unsafeFreeze v+ in LA.reshape n out++-- | Multi-input @K + σ_n² I@. Uses the fused @mkNoiseKernelFromD2@ so+-- that the kernel evaluation and jitter-on-diagonal write happen in a+-- single @n²@ pass rather than two.+noiseKernelMV :: Kernel -> GPParams -> LA.Matrix Double -> LA.Matrix Double+noiseKernelMV ker p x =+ let (xs, _, p') = ardScaleXY ker (gpKernelParams p) x x+ d2 = KD.pairwiseSqDist xs+ jitter = max (gpNoiseVar p) 1e-6+ in mkNoiseKernelFromD2 ker p' jitter d2++-- | Like 'noiseKernelMV' but reuses a pre-computed pairwise squared+-- distance matrix @D = pairwiseSqDist trainX@. Valid only when no ARD+-- scaling is applied (isotropic kernel) — the kernel is then a+-- function of @D@ alone, independent of length scale. Single-pass+-- (kernel + jitter fused).+noiseKernelMVCached+ :: Kernel -> GPParams -> LA.Matrix Double -> LA.Matrix Double+noiseKernelMVCached ker p d2 =+ let jitter = max (gpNoiseVar p) 1e-6+ in mkNoiseKernelFromD2 ker (gpKernelParams p) jitter d2++-- | D-cached version of 'logMarginalLikelihoodMV' — accepts a+-- pre-computed @D = pairwiseSqDist trainX@ instead of recomputing it+-- each call. Used by 'optimizeGPMV' in the isotropic case where @D@+-- is independent of the optimization variables.+logMarginalLikelihoodMVCached+ :: LA.Matrix Double -- ^ Pre-computed @D@ (@n × n@).+ -> LA.Vector Double -- ^ Training @y@ (length @n@).+ -> Kernel -> GPParams -> Double+logMarginalLikelihoodMVCached d2 y ker params =+ let n = LA.rows d2+ ky = noiseKernelMVCached ker params d2+ mR = case Chol.cholFactor ky of+ Just r -> Just (r, ky)+ Nothing ->+ let kyJ = addToDiag 1e-4 ky+ in case Chol.cholFactor kyJ of+ Just r -> Just (r, kyJ)+ Nothing -> Nothing+ in case mR of+ Nothing -> -1e30+ Just (r, _kyUsed) ->+ let logDet = 2 * VS.sum (VS.map log (LA.takeDiag r))+ alpha = LA.flatten+ (Chol.cholSolveWithFactor r (LA.asColumn y))+ dataFit = LA.dot y alpha+ in -0.5 * dataFit - 0.5 * logDet+ - fromIntegral n / 2 * log (2 * pi)++-- | Multi-input log marginal likelihood.+logMarginalLikelihoodMV+ :: LA.Matrix Double -- ^ Training @X@ (@n × p@).+ -> LA.Vector Double -- ^ Training @y@ (length @n@).+ -> Kernel -> GPParams -> Double+logMarginalLikelihoodMV trainX y ker params =+ let n = LA.rows trainX+ ky = noiseKernelMV ker params trainX+ mR = case Chol.cholFactor ky of+ Just r -> Just (r, ky)+ Nothing ->+ let kyJ = addToDiag 1e-4 ky+ in case Chol.cholFactor kyJ of+ Just r -> Just (r, kyJ)+ Nothing -> Nothing+ in case mR of+ Nothing -> -1e30+ Just (r, _kyUsed) ->+ let logDet = 2 * VS.sum (VS.map log (LA.takeDiag r))+ alpha = LA.flatten+ (Chol.cholSolveWithFactor r (LA.asColumn y))+ dataFit = LA.dot y alpha+ in -0.5 * dataFit - 0.5 * logDet+ - fromIntegral n / 2 * log (2 * pi)++-- | Multi-input single-output GP posterior prediction.+fitGPMV+ :: GPModel+ -> LA.Matrix Double -- ^ Training @X@ (@n × p@).+ -> LA.Vector Double -- ^ Training @y@ (length @n@).+ -> LA.Matrix Double -- ^ Test @X_*@ (@m × p@).+ -> GPResultMV+fitGPMV model trainX y testX =+ let yMat = LA.asColumn y+ (meanMat, varVec) = fitGPMVMulti model trainX yMat testX+ mu = LA.flatten (meanMat LA.¿ [0])+ stdVec = LA.cmap sqrt varVec+ in GPResultMV+ { gpmvTestX = testX+ , gpmvMean = mu+ , gpmvVar = varVec+ , gpmvLower = mu - LA.scale 2 stdVec+ , gpmvUpper = mu + LA.scale 2 stdVec+ }++-- | Multi-input multi-output GP posterior prediction. @Y@ has shape+-- @n × q@ (one column per output task). The variance does not depend on+-- @y@, so a single length-@m@ vector is shared by every output.+fitGPMVMulti+ :: GPModel+ -> LA.Matrix Double -- ^ Training @X@ (@n × p@).+ -> LA.Matrix Double -- ^ Training @Y@ (@n × q@).+ -> LA.Matrix Double -- ^ Test @X_*@ (@m × p@).+ -> (LA.Matrix Double, LA.Vector Double)+fitGPMVMulti model trainX trainY testX =+ let ker = gpKernel model+ params = gpParams model+ ky = noiseKernelMV ker params trainX+ kStar = buildKernelMatrixMV ker (gpKernelParams params) testX trainX -- m × n+ -- α = Ky⁻¹ Y via SPD Cholesky (reused for v below by passing both+ -- right-hand sides through the same factorization).+ rhs = trainY LA.||| LA.tr kStar -- n × (q + m)+ sol = Chol.cholSolveJitter ky rhs -- n × (q + m)+ q = LA.cols trainY+ alpha = sol LA.?? (LA.All, LA.Take q) -- n × q+ v = sol LA.?? (LA.All, LA.Drop q) -- n × m+ meanMt = kStar LA.<> alpha -- m × q+ sf = gpSignalVar params+ diagKss = LA.konst sf (LA.rows testX) -- k(x*, x*) = σ_f²+ -- F1: diagonal of (kStar · v) without forming the m×m product.+ -- 'KD.diagAB' = element-wise (kStar ⊙ vᵀ) · ones.+ varVec = LA.cmap (max 0) (diagKss - KD.diagAB kStar v)+ -- Tested split-solve (alpha and v separately via cholFactor ++ -- cholSolveWithFactor, avoiding the concat allocation) but the+ -- saving is dwarfed by the @O(n² · (q+m))@ triangular-solve+ -- work itself. Keep the simpler concatenated form.+ in (meanMt, varVec)++-- | Multi-input GP hyperparameter optimization. Mirrors 'optimizeGP' but+-- accepts a multi-input training matrix.+--+-- When @gpLengthScales p0 = Just v@, optimizes per-dim length scales+-- (ARD): the parameter vector becomes+-- @[log ℓ_1, …, log ℓ_p, log σ_f², log σ_n²]@. Otherwise optimises the+-- isotropic @[log ℓ, log σ_f², log σ_n²]@.+optimizeGPMV+ :: Kernel -> LA.Matrix Double -> LA.Vector Double -> GPParams -> GPParams+optimizeGPMV ker trainX y p0 =+ optimizeGPMVCached ker Nothing trainX y p0++-- | Like 'optimizeGPMV' but accepts a /pre-computed/ pairwise squared+-- distance matrix. Used by 'Hanalyze.Model.MultiGP' to share @D = pairwiseSqDist+-- trainX@ across all @q@ outputs (the same @trainX@ is used for every+-- output, so re-computing @D@ inside each per-output optimisation is+-- pure waste). For ARD the cache is ignored (the kernel depends on+-- per-feature length scales and @D@ varies with the optimisation+-- variables).+optimizeGPMVCached+ :: Kernel+ -> Maybe (LA.Matrix Double) -- ^ Pre-computed @D = pairwiseSqDist trainX@.+ -> LA.Matrix Double+ -> LA.Vector Double+ -> GPParams+ -> GPParams+optimizeGPMVCached ker mPreD trainX y p0+ -- Analytic-gradient fast path for the isotropic non-ARD case under+ -- the RBF kernel. Replaces the central-difference numeric gradient+ -- (which costs 6 × the Cholesky-based log-marginal-likelihood+ -- evaluation per LBFGS step) with a closed-form formula that re-uses+ -- a single explicit @Ky⁻¹@ for all three parameters. See+ -- 'optimizeRBFAnalytic'.+ | ker == RBF && not (isARDOf p0 (LA.cols trainX)) =+ optimizeRBFAnalytic mPreD trainX y p0+ | otherwise =+ let cfg = LBFGS.defaultLBFGSConfig+ { LBFGS.lbDir = OC.Maximize+ , LBFGS.lbStop = OC.defaultStopCriteria+ { OC.stMaxIter = 200, OC.stTolFun = 1e-8 }+ }+ u0v = LA.fromList initU+ -- Vector-native objective: takes the LBFGS state Vector directly.+ -- Saves the list conversion that 'runLBFGSNumeric' / 'runLBFGSWith'+ -- do on every objective and gradient call.+ objV uv = obj (LA.toList uv)+ -- Central-difference gradient on the Vector representation. We+ -- experimented with forward differences (half the evaluations+ -- per gradient) but L-BFGS needed more iterations to converge+ -- under the looser O(h) error, giving a net wall-time regression.+ h = 1e-5 :: Double+ gradV uv =+ let n = LA.size uv+ in LA.fromList+ [ let plus = uv VS.// [(i, uv VS.! i + h)]+ minus = uv VS.// [(i, uv VS.! i - h)]+ in (objV plus - objV minus) / (2 * h)+ | i <- [0 .. n - 1] ]+ result = unsafePerformIO $ LBFGS.runLBFGSWithV cfg objV gradV u0v+ uOpt = OC.orBest result+ in toParams uOpt+ where+ p = LA.cols trainX+ isARD = case gpLengthScales p0 of+ Just v | LA.size v == p && p > 0 -> True+ _ -> False+ -- Pre-compute the pairwise squared distance matrix for the+ -- isotropic case. The kernel of every supported family is a+ -- function of @D@ alone (length scale enters via @applyKernel@),+ -- so the LBFGS log-marginal-likelihood loop reuses @D@ instead of+ -- recomputing 'pairwiseSqDist' on every evaluation. Profile+ -- (see bench/results/) showed 'pairwiseSqDist' was 26.8% of+ -- 'optimizeGPMV' wall time before this cache.+ -- For ARD, the per-dim length scales rescale columns of @X@, so+ -- @D@ depends on the optimization variables and cannot be cached.+ cachedD :: Maybe (LA.Matrix Double)+ cachedD+ | isARD = Nothing+ | otherwise = case mPreD of+ Just d -> Just d -- caller-supplied+ Nothing -> Just (KD.pairwiseSqDist trainX) -- compute now+ initU+ | isARD = case gpLengthScales p0 of+ Just v ->+ let ls = LA.toList v+ in map log ls+ ++ [log (gpSignalVar p0), log (gpNoiseVar p0)]+ Nothing ->+ -- Cannot happen: isARD already requires Just.+ [ log (gpLengthScale p0)+ , log (gpSignalVar p0)+ , log (gpNoiseVar p0) ]+ | otherwise = [ log (gpLengthScale p0)+ , log (gpSignalVar p0)+ , log (gpNoiseVar p0) ]+ toParams u+ | isARD =+ let lsV = LA.fromList (map exp (take p u))+ in p0+ { gpLengthScales = Just lsV+ , gpSignalVar = exp (u !! p)+ , gpNoiseVar = exp (u !! (p + 1))+ }+ | otherwise = p0+ { gpLengthScale = exp (u !! 0)+ , gpSignalVar = exp (u !! 1)+ , gpNoiseVar = exp (u !! 2)+ }+ -- For ARD, add a weak log-normal prior on each ℓ_d centred at the+ -- initial value (Gaussian in log-space, σ_prior = 1.5 ≈ ratio 4.5).+ -- Without it, log marginal likelihood with only 30 BO points and+ -- many ℓ_d's tends to drive ℓ_d to extreme values (over-fit). The+ -- prior is informative enough to keep ℓ_d within ~one order of+ -- magnitude of the init while still letting individual dims relax.+ obj u+ | isARD =+ case gpLengthScales p0 of+ Just v0 ->+ let lml = logMarginalLikelihoodMV trainX y ker (toParams u)+ logL0 = map log (LA.toList v0)+ sig2 = 1.5 * 1.5+ prior = sum [ -0.5 * (l - l0) ^ (2 :: Int) / sig2+ | (l, l0) <- zip (take p u) logL0 ]+ in lml + prior+ Nothing ->+ -- Cannot happen by isARD construction; fall back to+ -- the un-prior-ed ARD likelihood.+ logMarginalLikelihoodMV trainX y ker (toParams u)+ | otherwise =+ case cachedD of+ Just d2 -> logMarginalLikelihoodMVCached d2 y ker (toParams u)+ Nothing -> logMarginalLikelihoodMV trainX y ker (toParams u)++-- | Whether the given 'GPParams' / input dimension imply ARD.+isARDOf :: GPParams -> Int -> Bool+isARDOf p0 p = case gpLengthScales p0 of+ Just v | LA.size v == p && p > 0 -> True+ _ -> False++-- | Analytic-gradient L-BFGS for the isotropic RBF GP marginal+-- likelihood. Replaces the central-difference numeric gradient (6 extra+-- evaluations per LBFGS step) with a closed-form formula that re-uses+-- a single explicit @Ky⁻¹@ across all three parameters+-- @[log ℓ, log σ_f², log σ_n²]@.+--+-- For RBF, @∂Ky/∂(log θ_k)@ is:+--+-- * @log ℓ@: @K ⊙ (D / ℓ²)@+-- * @log σ_f²@: @K@ (linear in @σ_f²@)+-- * @log σ_n²@: @σ_n² · I@+--+-- and the gradient contribution is+-- @½ tr((α αᵀ − Ky⁻¹) ∂Ky/∂(log θ_k))@. We form @Ky⁻¹@ once per LBFGS+-- step (@O(n³)@ via @cholSolveJitter ky I@) and assemble each+-- coordinate of the gradient via element-wise sums (@O(n²)@). Total+-- work per step: roughly @n³/2 + O(n²)@ vs the numeric path's+-- @≈ n³ + O(n²)@, plus L-BFGS converges in fewer iterations when fed+-- exact gradients.+optimizeRBFAnalytic+ :: Maybe (LA.Matrix Double) -> LA.Matrix Double -> LA.Vector Double+ -> GPParams -> GPParams+optimizeRBFAnalytic mPreD trainX y p0 =+ let n = LA.rows trainX+ d2 = case mPreD of+ Just d -> d+ Nothing -> KD.pairwiseSqDist trainX+ cfg = LBFGS.defaultLBFGSConfig+ { LBFGS.lbDir = OC.Maximize+ , LBFGS.lbStop = OC.defaultStopCriteria+ { OC.stMaxIter = 200+ , OC.stTolFun = 1e-8 }+ }+ u0v = LA.fromList+ [ log (gpLengthScale p0)+ , log (gpSignalVar p0)+ , log (gpNoiseVar p0) ]++ -- Build the kernel matrix and noise-augmented matrix from+ -- params (re-using the precomputed @D@).+ buildK uv =+ let !ll = exp (uv VS.! 0) -- length scale ℓ+ !sf2 = exp (uv VS.! 1) -- σ_f²+ !sn2 = exp (uv VS.! 2) -- σ_n²+ !inv2L2 = 1 / (2 * ll * ll)+ !kMat = LA.cmap (\s -> sf2 * exp (- s * inv2L2)) d2+ !ky = addToDiag sn2 kMat+ in (ll, sf2, sn2, kMat, ky)++ -- Objective only (used by L-BFGS line search).+ objV uv =+ let (_, _, _, _, ky) = buildK uv+ in case Chol.cholFactor ky of+ Nothing -> -1e30+ Just r ->+ let logDet = 2 * VS.sum (VS.map log (LA.takeDiag r))+ alpha = LA.flatten+ (Chol.cholSolveWithFactor r (LA.asColumn y))+ dataFit = LA.dot y alpha+ in -0.5 * dataFit - 0.5 * logDet+ - fromIntegral n / 2 * log (2 * pi)++ -- Analytic gradient.+ gradV uv =+ let (ll, _sf2, sn2, kMat, ky) = buildK uv+ in case Chol.cholFactor ky of+ Nothing -> LA.fromList [0, 0, 0] -- bail out at singular Ky+ Just r ->+ let alpha = LA.flatten+ (Chol.cholSolveWithFactor r (LA.asColumn y))+ -- Explicit @Ky⁻¹@ (n × n). 'cholSolveWithFactor'+ -- against the n×n identity is an @O(n³)@ pair of+ -- triangular solves but only happens once per LBFGS+ -- gradient call.+ kyInv = Chol.cholSolveWithFactor r (LA.ident n)+ -- Q = α αᵀ − Ky⁻¹. We don't materialise this+ -- separately; instead each gradient component is+ -- computed as @α^T V α − tr(Ky⁻¹ V)@ inline.+ --+ -- ∂Ky/∂(log ℓ) = K ⊙ (D / ℓ²)+ !invL2 = 1 / (ll * ll)+ !vL = LA.scale invL2 (kMat * d2)+ !aT_vL = LA.dot alpha (vL LA.#> alpha)+ !tr_KyInv_vL = LA.sumElements (kyInv * vL)+ !gLogL = 0.5 * (aT_vL - tr_KyInv_vL)+ -- ∂Ky/∂(log σ_f²) = K+ !aT_K = LA.dot alpha (kMat LA.#> alpha)+ !tr_KyInv_K = LA.sumElements (kyInv * kMat)+ !gLogSf = 0.5 * (aT_K - tr_KyInv_K)+ -- ∂Ky/∂(log σ_n²) = σ_n² I+ !aT_a = LA.dot alpha alpha+ !tr_KyInv = LA.sumElements (LA.takeDiag kyInv)+ !gLogSn = 0.5 * sn2 * (aT_a - tr_KyInv)+ in LA.fromList [gLogL, gLogSf, gLogSn]++ result = unsafePerformIO $ LBFGS.runLBFGSWithV cfg objV gradV u0v+ uOpt = OC.orBest result+ in p0+ { gpLengthScale = exp (uOpt !! 0)+ , gpSignalVar = exp (uOpt !! 1)+ , gpNoiseVar = exp (uOpt !! 2)+ }
+ src/Hanalyze/Model/GPRobust.hs view
@@ -0,0 +1,395 @@+{-# LANGUAGE OverloadedStrings #-}+-- |+-- Module : Hanalyze.Model.GPRobust+-- Description : ロバストガウス過程 (重尾観測尤度: Student-t / Cauchy)+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- [日本語]: ロバスト GP (重尾観測尤度)。+--+-- 閉形式の Gaussian 尤度 GP は外れ値に敏感である。 本モジュールは観測尤度を+-- Student-t または Cauchy に置き換え、 IRLS 形式のスキーム (variational EM /+-- Laplace の安定な変種) を反復して MAP 推定を得る。+--+-- アルゴリズム:+--+-- 1. @f ← 0@ (GP 事前平均)。+-- 2. 収束するまで反復:+-- a. 残差 @r = y − f@。+-- b. 観測ごとの重みを計算:+-- - Student-t @(ν, σ)@: @w_i = (ν + 1) / (ν + (r_i/σ)²)@。+-- - Cauchy @(γ)@: @w_i = 2 / (1 + (r_i/γ)²)@。+-- c. 各点の有効ノイズ分散 σ²/w_i (heteroscedastic)+-- d. f ← K (K + σ² W⁻¹)⁻¹ y+-- 3. 予測点 x* で:+-- mean = k_*ᵀ (K + σ² W⁻¹)⁻¹ y+-- var = k(x*,x*) − k_*ᵀ (K + σ² W⁻¹)⁻¹ k_*+--+-- カーネル関連 ('Kernel', 'GPParams', 'kernelFn') は 'Hanalyze.Model.GP' を再利用。+--+-- [English]: Robust GP (heavy-tailed observation likelihoods).+--+-- A closed-form Gaussian-likelihood GP is sensitive to outliers. This+-- module replaces the observation likelihood with Student-t or Cauchy and+-- iterates an IRLS-style scheme (a stable variant of variational EM /+-- Laplace) to obtain a MAP estimate.+--+-- Algorithm:+--+-- 1. @f ← 0@ (GP prior mean).+-- 2. Iterate until convergence:+-- a. Residual @r = y − f@.+-- b. Compute the per-observation weight:+-- - Student-t @(ν, σ)@: @w_i = (ν + 1) / (ν + (r_i/σ)²)@.+-- - Cauchy @(γ)@: @w_i = 2 / (1 + (r_i/γ)²)@.+-- c. Each point's effective noise variance σ²/w_i (heteroscedastic).+-- d. f ← K (K + σ² W⁻¹)⁻¹ y.+-- 3. At a test point x*:+-- mean = k_*ᵀ (K + σ² W⁻¹)⁻¹ y+-- var = k(x*,x*) − k_*ᵀ (K + σ² W⁻¹)⁻¹ k_*+--+-- Kernel-related items ('Kernel', 'GPParams', 'kernelFn') are reused from+-- 'Hanalyze.Model.GP'.+module Hanalyze.Model.GPRobust+ ( -- * 観測尤度+ RobustLikelihood (..)+ , -- * フィット結果と推論+ RobustGPFit (..)+ , fitGPRobust+ , predictGPRobust+ -- * Multi-output (primary API)+ , RobustGPFitMulti (..)+ , fitGPRobustMulti+ , predictGPRobustMulti+ -- * Multi-input (primary API; X is @n × p@, Y is @n × q@)+ , RobustGPFitMV (..)+ , fitGPRobustMV+ , predictGPRobustMV+ , RobustGPFitMVMulti (..)+ , fitGPRobustMVMulti+ , predictGPRobustMVMulti+ ) where++import qualified Numeric.LinearAlgebra as LA+import qualified Hanalyze.Stat.Cholesky as Chol+import qualified Hanalyze.Stat.KernelDist as KD+import Hanalyze.Model.GP+ ( Kernel+ , GPParams (..)+ , gpKernelParams+ , kernelFn+ , buildKernelMatrix+ , buildKernelMatrixMV+ )++-- ---------------------------------------------------------------------------+-- 観測尤度+-- ---------------------------------------------------------------------------++-- | Heavy-tailed observation likelihood.+data RobustLikelihood+ = RGaussian Double -- ^ Gaussian @(σ_n)@ — equivalent to a+ -- standard GP (sanity-check baseline).+ | RStudentT Double Double -- ^ Student-t @(df=ν, scale=σ)@; smaller+ -- @ν@ means heavier tails.+ | RCauchy Double -- ^ Cauchy @(scale=γ)@, equivalent to+ -- @StudentT(1, γ)@.+ deriving (Show, Eq)++-- | IRLS weight @w(r)@ for residual @r@. The effective noise variance is+-- @σ_eff² / w_i@ at each step.+likelihoodWeight :: RobustLikelihood -> Double -> Double+likelihoodWeight (RGaussian _) _ = 1.0+likelihoodWeight (RStudentT nu sigma) r =+ let z = r / sigma+ in (nu + 1) / (nu + z * z)+likelihoodWeight (RCauchy gamma) r =+ let z = r / gamma+ in 2 / (1 + z * z)++-- | Reference variance @σ_eff²@ used to scale the IRLS weights.+likelihoodScale2 :: RobustLikelihood -> Double+likelihoodScale2 (RGaussian s) = s * s+likelihoodScale2 (RStudentT _ s) = s * s+likelihoodScale2 (RCauchy g) = g * g++-- ---------------------------------------------------------------------------+-- フィット結果+-- ---------------------------------------------------------------------------++-- | Robust GP fit result.+data RobustGPFit = RobustGPFit+ { rgpKernel :: Kernel+ , rgpParams :: GPParams+ , rgpLik :: RobustLikelihood+ , rgpTrainX :: [Double] -- ^ Training inputs.+ , rgpTrainY :: [Double] -- ^ Training targets.+ , rgpAlpha :: LA.Vector Double -- ^ @α = (K + σ² W⁻¹)⁻¹ y@.+ , rgpKyInv :: LA.Matrix Double -- ^ @(K + σ² W⁻¹)⁻¹@ at convergence.+ , rgpWeights :: LA.Vector Double -- ^ IRLS weights at convergence.+ , rgpIters :: Int -- ^ Number of IRLS iterations executed.+ } deriving (Show)++-- ---------------------------------------------------------------------------+-- フィット+-- ---------------------------------------------------------------------------++-- | Compute the MAP of a robust GP via IRLS iteration. At most 50+-- iterations; convergence when @‖f_new − f‖∞ < 10⁻⁶@.+fitGPRobust+ :: Kernel+ -> GPParams -- ^ Kernel hyperparameters (held fixed —+ -- optimize them separately).+ -> RobustLikelihood+ -> [Double] -- ^ Training @X@.+ -> [Double] -- ^ Training @Y@.+ -> RobustGPFit+fitGPRobust ker params lik trainX trainY =+ let n = length trainX+ kMatrix = buildKernelMatrix ker (gpKernelParams params) trainX trainX -- K (n×n)+ yV = LA.fromList trainY+ sigEff2 = likelihoodScale2 lik+ -- 1 反復: f, w を更新+ step (f, w, _iter) =+ let r = LA.toList (yV - f)+ wNew' = [ max 1e-8 (likelihoodWeight lik ri)+ | ri <- r ]+ wNewVec = LA.fromList wNew'+ wInvDiag = LA.diag (LA.fromList [ sigEff2 / wi | wi <- wNew' ])+ ky = kMatrix `LA.add` wInvDiag+ -- α = (K + σ²W⁻¹)⁻¹ y via SPD Cholesky (replaces inv + matvec).+ alpha = LA.flatten+ (Chol.cholSolveJitter ky (LA.asColumn yV))+ fNew = kMatrix LA.#> alpha+ delta = LA.maxElement (LA.cmap abs (fNew - f))+ in (fNew, wNewVec, delta)++ maxIters = 50+ tol = 1e-6 :: Double++ loop f w iter+ | iter >= maxIters = (f, w, iter)+ | otherwise =+ let (fNew, wNew, delta) = step (f, w, iter)+ in if delta < tol+ then (fNew, wNew, iter + 1)+ else loop fNew wNew (iter + 1)++ f0 = LA.fromList (replicate n 0.0)+ w0 = LA.fromList (replicate n 1.0)+ (_fOpt, wOpt, iters) = loop f0 w0 0++ -- 最終 K_y, α, K_y⁻¹ を再計算 (収束後の重みで)。+ -- kyInv は予測時の分散計算で必要なため陽に保持する。+ wInvDiag' = LA.diag (LA.cmap (\wi -> sigEff2 / max 1e-8 wi) wOpt)+ ky' = kMatrix `LA.add` wInvDiag'+ kyInv' = Chol.cholSolveJitter ky' (LA.ident n)+ alpha' = LA.flatten+ (Chol.cholSolveJitter ky' (LA.asColumn yV))+ in RobustGPFit+ { rgpKernel = ker+ , rgpParams = params+ , rgpLik = lik+ , rgpTrainX = trainX+ , rgpTrainY = trainY+ , rgpAlpha = alpha'+ , rgpKyInv = kyInv'+ , rgpWeights = wOpt+ , rgpIters = iters+ }++-- ---------------------------------------------------------------------------+-- 予測+-- ---------------------------------------------------------------------------++-- | Predictive mean and variance of @f@ at the given test points.+-- mean = k_*ᵀ α, var = k(x*,x*) − k_*ᵀ K_y⁻¹ k_*+predictGPRobust :: RobustGPFit -> [Double] -> [(Double, Double)]+predictGPRobust fit testX =+ let ker = rgpKernel fit+ params = rgpParams fit+ trainX = rgpTrainX fit+ kStar = buildKernelMatrix ker (gpKernelParams params) testX trainX -- (m, n)+ means = LA.toList (kStar LA.#> rgpAlpha fit)+ kyInv = rgpKyInv fit+ diagKss = [ kernelFn ker (gpKernelParams params) x x | x <- testX ]+ ws = kStar LA.<> kyInv -- (m, n)+ -- F1: vectorise per-row dots.+ rowDots = LA.toList (KD.rowDotsAB kStar ws)+ varList = zipWith (\d kw -> max 0 (d - kw)) diagKss rowDots+ in zip means varList++-- ---------------------------------------------------------------------------+-- 多出力 (列ごと IRLS、カーネル行列を共有)+-- ---------------------------------------------------------------------------++-- | [日本語]: 多出力ロバスト GP の結果。q 出力ぶんの 'RobustGPFit' を保持し、+-- カーネル / ハイパラ / 尤度は共通。+-- [English]: The result of a multi-output robust GP. Holds a+-- 'RobustGPFit' per output (q of them); the kernel \/ hyperparameters \/+-- likelihood are shared.+data RobustGPFitMulti = RobustGPFitMulti+ { rgmKernel :: Kernel+ , rgmParams :: GPParams+ , rgmLik :: RobustLikelihood+ , rgmTrainX :: [Double]+ , rgmFits :: [RobustGPFit] -- ^ [日本語]: 列ごとの単出力 fit [English]: Per-column single-output fit+ } deriving (Show)++-- | [日本語]: 多出力ロバスト GP fit。Y は n × q、各列ごとに IRLS (重みは出力依存)。+-- [English]: Multi-output robust GP fit. Y has shape n × q; IRLS runs+-- per column (weights depend on the output).+fitGPRobustMulti+ :: Kernel+ -> GPParams+ -> RobustLikelihood+ -> [Double] -- ^ [日本語]: 訓練 X [English]: Training X+ -> LA.Matrix Double -- ^ [日本語]: Y (n × q) [English]: Y (n × q)+ -> RobustGPFitMulti+fitGPRobustMulti ker params lik trainX yMat =+ let q = LA.cols yMat+ yCols = [ LA.toList (LA.flatten (yMat LA.¿ [j])) | j <- [0 .. q - 1] ]+ fits = [ fitGPRobust ker params lik trainX y | y <- yCols ]+ in RobustGPFitMulti ker params lik trainX fits++-- | [日本語]: 多出力ロバスト GP 予測。戻り値: (mean 行列 m × q, 列ごとの分散リスト)。+-- [English]: Multi-output robust GP prediction. Returns (mean matrix+-- m × q, per-column variance lists).+predictGPRobustMulti :: RobustGPFitMulti -> [Double]+ -> (LA.Matrix Double, [[Double]])+predictGPRobustMulti mf testX =+ let preds = [ predictGPRobust f testX | f <- rgmFits mf ]+ meansCols = map (map fst) preds+ varsCols = map (map snd) preds+ meansMat = LA.fromColumns [ LA.fromList col | col <- meansCols ]+ in (meansMat, varsCols)++-- ---------------------------------------------------------------------------+-- Multi-input (multivariate X) API+-- ---------------------------------------------------------------------------++-- | Robust GP fit with multivariate input. Mirrors 'RobustGPFit' but+-- stores @X@ as an @n × p@ matrix and @y@ as a 'LA.Vector'.+data RobustGPFitMV = RobustGPFitMV+ { rgpmvKernel :: Kernel+ , rgpmvParams :: GPParams+ , rgpmvLik :: RobustLikelihood+ , rgpmvTrainX :: LA.Matrix Double -- ^ @n × p@.+ , rgpmvTrainY :: LA.Vector Double -- ^ length @n@.+ , rgpmvAlpha :: LA.Vector Double+ , rgpmvKyInv :: LA.Matrix Double+ , rgpmvWeights :: LA.Vector Double+ , rgpmvIters :: Int+ } deriving (Show)++-- | Compute the MAP of a multi-input robust GP via the same IRLS scheme+-- as 'fitGPRobust'. @X@ is @n × p@; @y@ has length @n@.+fitGPRobustMV+ :: Kernel+ -> GPParams+ -> RobustLikelihood+ -> LA.Matrix Double -- ^ Training @X@ (@n × p@).+ -> LA.Vector Double -- ^ Training @y@ (length @n@).+ -> RobustGPFitMV+fitGPRobustMV ker params lik trainX yV =+ let n = LA.rows trainX+ kMatrix = buildKernelMatrixMV ker (gpKernelParams params) trainX trainX+ sigEff2 = likelihoodScale2 lik+ step (f, w, _iter) =+ let r = LA.toList (yV - f)+ wNew' = [ max 1e-8 (likelihoodWeight lik ri) | ri <- r ]+ wNewVec = LA.fromList wNew'+ wInvDiag = LA.diag (LA.fromList [ sigEff2 / wi | wi <- wNew' ])+ ky = kMatrix `LA.add` wInvDiag+ -- α = (K + σ²W⁻¹)⁻¹ y via SPD Cholesky.+ alpha = LA.flatten+ (Chol.cholSolveJitter ky (LA.asColumn yV))+ fNew = kMatrix LA.#> alpha+ delta = LA.maxElement (LA.cmap abs (fNew - f))+ in (fNew, wNewVec, delta)++ maxIters = 50+ tol = 1e-6 :: Double++ loop f w iter+ | iter >= maxIters = (f, w, iter)+ | otherwise =+ let (fNew, wNew, delta) = step (f, w, iter)+ in if delta < tol+ then (fNew, wNew, iter + 1)+ else loop fNew wNew (iter + 1)++ f0 = LA.fromList (replicate n 0.0)+ w0 = LA.fromList (replicate n 1.0)+ (_fOpt, wOpt, iters) = loop f0 w0 0++ wInvDiag' = LA.diag (LA.cmap (\wi -> sigEff2 / max 1e-8 wi) wOpt)+ ky' = kMatrix `LA.add` wInvDiag'+ kyInv' = Chol.cholSolveJitter ky' (LA.ident n)+ alpha' = LA.flatten+ (Chol.cholSolveJitter ky' (LA.asColumn yV))+ in RobustGPFitMV+ { rgpmvKernel = ker+ , rgpmvParams = params+ , rgpmvLik = lik+ , rgpmvTrainX = trainX+ , rgpmvTrainY = yV+ , rgpmvAlpha = alpha'+ , rgpmvKyInv = kyInv'+ , rgpmvWeights = wOpt+ , rgpmvIters = iters+ }++-- | Predictive mean and variance at multi-input test points (@m × p@).+predictGPRobustMV+ :: RobustGPFitMV -> LA.Matrix Double+ -> (LA.Vector Double, LA.Vector Double)+predictGPRobustMV fit testX =+ let ker = rgpmvKernel fit+ params = rgpmvParams fit+ trainX = rgpmvTrainX fit+ kStar = buildKernelMatrixMV ker (gpKernelParams params) testX trainX -- m × n+ means = kStar LA.#> rgpmvAlpha fit+ kyInv = rgpmvKyInv fit+ sf = gpSignalVar params+ diagKss = LA.konst sf (LA.rows testX)+ ws = kStar LA.<> kyInv -- m × n+ -- F1: vectorise per-row dots.+ vars = LA.cmap (max 0) (diagKss - KD.rowDotsAB kStar ws)+ in (means, vars)++-- | Multi-input multi-output robust GP. Per-column IRLS (weights are+-- output-specific), but the kernel matrix @K@ is shared.+data RobustGPFitMVMulti = RobustGPFitMVMulti+ { rgmvKernel :: Kernel+ , rgmvParams :: GPParams+ , rgmvLik :: RobustLikelihood+ , rgmvTrainX :: LA.Matrix Double+ , rgmvFits :: [RobustGPFitMV]+ } deriving (Show)++-- | Fit a multi-input multi-output robust GP. @Y@ has shape @n × q@.+fitGPRobustMVMulti+ :: Kernel+ -> GPParams+ -> RobustLikelihood+ -> LA.Matrix Double -- ^ Training @X@ (@n × p@).+ -> LA.Matrix Double -- ^ Training @Y@ (@n × q@).+ -> RobustGPFitMVMulti+fitGPRobustMVMulti ker params lik trainX yMat =+ let q = LA.cols yMat+ cols = [ LA.flatten (yMat LA.¿ [j]) | j <- [0 .. q - 1] ]+ fits = [ fitGPRobustMV ker params lik trainX y | y <- cols ]+ in RobustGPFitMVMulti ker params lik trainX fits++-- | Multi-input multi-output robust GP prediction. Returns the @m × q@+-- mean matrix and a per-column variance vector.+predictGPRobustMVMulti+ :: RobustGPFitMVMulti -> LA.Matrix Double+ -> (LA.Matrix Double, [LA.Vector Double])+predictGPRobustMVMulti mf testX =+ let preds = [ predictGPRobustMV f testX | f <- rgmvFits mf ]+ meanCs = map fst preds+ varCs = map snd preds+ meanMat = LA.fromColumns meanCs+ in (meanMat, varCs)
+ src/Hanalyze/Model/GradientBoosting.hs view
@@ -0,0 +1,207 @@+{-# LANGUAGE BangPatterns #-}+-- |+-- Module : Hanalyze.Model.GradientBoosting+-- Description : 勾配ブースティング (Gradient Boosting Machine、 回帰 + 二値分類)+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- [日本語]: Gradient Boosting Machine (回帰 + 二値分類)。+--+-- 弱学習器は 'Hanalyze.Model.RandomForest' の回帰木 ('RF.Tree' /+-- 'RF.buildTreeV') を流用 (bootstrap 無 + mtry = d で full-data /+-- 全特徴を使う通常の GBM 木に縮約)。+--+-- @+-- import qualified Hanalyze.Model.GradientBoosting as GB+-- gb <- GB.fitGBRegressor GB.defaultGBM x y+-- let yhat = GB.predictGBR gb x+-- @+--+-- 損失:+--+-- - 回帰: 二乗誤差 (negative gradient = 残差)+-- - 分類 (binary): log-loss (negative gradient = y - sigmoid(F))+--+-- [English]: Gradient Boosting Machine (regression + binary classification).+--+-- The weak learner reuses the regression tree from+-- 'Hanalyze.Model.RandomForest' ('RF.Tree' \/ 'RF.buildTreeV'),+-- reduced to an ordinary GBM tree that uses full data \/ all features (no+-- bootstrap, mtry = d).+--+-- @+-- import qualified Hanalyze.Model.GradientBoosting as GB+-- gb <- GB.fitGBRegressor GB.defaultGBM x y+-- let yhat = GB.predictGBR gb x+-- @+--+-- Loss:+--+-- - Regression: squared error (negative gradient = the residual).+-- - Classification (binary): log-loss (negative gradient =+-- y - sigmoid(F)).+module Hanalyze.Model.GradientBoosting+ ( GBConfig (..)+ , defaultGBM+ , GBRegressor (..)+ , GBClassifier (..)+ , fitGBRegressor+ , fitGBClassifier+ , predictGBR+ , predictGBRRow+ , predictGBC+ , predictGBCProbs+ ) where++import qualified Data.Vector.Unboxed as VU+import qualified Numeric.LinearAlgebra as LA+import qualified Hanalyze.Model.RandomForest as RF++-- ---------------------------------------------------------------------------+-- Config+-- ---------------------------------------------------------------------------++-- | [日本語]: GBM 設定。+-- [English]: The GBM configuration.+data GBConfig = GBConfig+ { gbNRounds :: !Int -- ^ [日本語]: ブースティング回数 M。 [English]: The number of boosting rounds M.+ , gbMaxDepth :: !Int -- ^ [日本語]: 各弱学習器の最大深さ (典型 3-5)。 [English]: The maximum depth of each weak learner (typically 3-5).+ , gbMinSamples :: !Int -- ^ [日本語]: 葉最小サンプル数。 [English]: The minimum number of samples per leaf.+ , gbLearnRate :: !Double -- ^ [日本語]: 学習率 η (typ 0.1)。 [English]: The learning rate η (typically 0.1).+ } deriving (Show)++defaultGBM :: GBConfig+defaultGBM = GBConfig+ { gbNRounds = 100+ , gbMaxDepth = 3+ , gbMinSamples = 2+ , gbLearnRate = 0.1+ }++-- | [日本語]: 弱学習器設定 (full-data / 全特徴利用、 木の深さは gbMaxDepth)。+-- [English]: The weak-learner configuration (uses full data \/ all+-- features; tree depth is gbMaxDepth).+weakRFCfg :: Int -> GBConfig -> RF.RFConfig+weakRFCfg d cfg = RF.RFConfig+ { RF.rfTrees = 1+ , RF.rfMaxDepth = gbMaxDepth cfg+ , RF.rfMinSamples = gbMinSamples cfg+ , RF.rfMtry = Just d+ , RF.rfBootstrap = False+ }++-- ---------------------------------------------------------------------------+-- Regressor+-- ---------------------------------------------------------------------------++-- | [日本語]: 回帰 GBM。 予測 = init + η · Σ tree_m(x)。+-- [English]: A regression GBM. Prediction = init + η · Σ tree_m(x).+data GBRegressor = GBRegressor+ { gbrInit :: !Double+ , gbrTrees :: ![RF.Tree]+ , gbrLR :: !Double+ } deriving (Show)++fitGBRegressor :: GBConfig+ -> LA.Matrix Double -- ^ [日本語]: X (n × d)。 [English]: X (n × d).+ -> VU.Vector Double -- ^ [日本語]: y (n)。 [English]: y (n).+ -> GBRegressor+fitGBRegressor cfg x y =+ let !n = VU.length y+ !d = LA.cols x+ !cfgW = weakRFCfg d cfg+ !lr = gbLearnRate cfg+ !f0 = VU.sum y / fromIntegral n+ !preds0 = VU.replicate n f0+ idx = VU.enumFromN 0 n++ step (!preds, !trees) _ =+ let !res = VU.zipWith (-) y preds+ !t = RF.buildTreeV cfgW x res idx 0+ !upd = VU.map (\i -> lr * RF.predictTree t (rowList x i))+ (VU.enumFromN 0 n)+ !preds' = VU.zipWith (+) preds upd+ in (preds', t : trees)++ (_, treesRev) = foldl step (preds0, []) [1 .. gbNRounds cfg]+ in GBRegressor f0 (reverse treesRev) lr++-- | [日本語]: 1 行を [Double] 化 (predictTree のための一時変換)。+-- [English]: Converts a single row to [Double] (a temporary conversion+-- for predictTree).+rowList :: LA.Matrix Double -> Int -> [Double]+rowList x i = LA.toList (LA.flatten (x LA.? [i]))++-- | [日本語]: 1 サンプルの予測。+-- [English]: Predicts a single sample.+predictGBRRow :: GBRegressor -> [Double] -> Double+predictGBRRow gb xs =+ gbrInit gb+ + gbrLR gb * sum [ RF.predictTree t xs | t <- gbrTrees gb ]++-- | [日本語]: 行列入力に対する予測 (n)。+-- [English]: Predicts for matrix input (n).+predictGBR :: GBRegressor -> LA.Matrix Double -> VU.Vector Double+predictGBR gb x =+ let !n = LA.rows x+ in VU.generate n (\i -> predictGBRRow gb (rowList x i))++-- ---------------------------------------------------------------------------+-- Classifier (binary)+-- ---------------------------------------------------------------------------++-- | [日本語]: 二値分類 GBM (logit + log-loss)。 ラベルは 0/1。+-- [English]: A binary-classification GBM (logit + log-loss). Labels are+-- 0\/1.+data GBClassifier = GBClassifier+ { gbcInit :: !Double -- ^ [日本語]: logit(p̂_0)。 [English]: logit(p̂_0).+ , gbcTrees :: ![RF.Tree]+ , gbcLR :: !Double+ } deriving (Show)++sigmoid :: Double -> Double+sigmoid z = 1 / (1 + exp (negate z))++clamp :: Double -> Double -> Double -> Double+clamp lo hi v = max lo (min hi v)++fitGBClassifier :: GBConfig+ -> LA.Matrix Double -- ^ [日本語]: X (n × d)。 [English]: X (n × d).+ -> VU.Vector Int -- ^ [日本語]: y ∈ {0,1} (n)。 [English]: y ∈ {0,1} (n).+ -> GBClassifier+fitGBClassifier cfg x y =+ let !n = VU.length y+ !d = LA.cols x+ !cfgW = weakRFCfg d cfg+ !lr = gbLearnRate cfg+ !yD = VU.map fromIntegral y :: VU.Vector Double+ !p0 = clamp 1e-6 (1 - 1e-6) (VU.sum yD / fromIntegral n)+ !f0 = log (p0 / (1 - p0))+ !logits0 = VU.replicate n f0+ idx = VU.enumFromN 0 n++ step (!logits, !trees) _ =+ let !grad = VU.zipWith (\yi z -> yi - sigmoid z) yD logits+ !t = RF.buildTreeV cfgW x grad idx 0+ !upd = VU.map (\i -> lr * RF.predictTree t (rowList x i))+ (VU.enumFromN 0 n)+ !logits' = VU.zipWith (+) logits upd+ in (logits', t : trees)++ (_, treesRev) = foldl step (logits0, []) [1 .. gbNRounds cfg]+ in GBClassifier f0 (reverse treesRev) lr++-- | [日本語]: クラス確率 p(y=1 | x) を返す。+-- [English]: Returns the class probability p(y=1 | x).+predictGBCProbs :: GBClassifier -> LA.Matrix Double -> VU.Vector Double+predictGBCProbs gb x =+ let !n = LA.rows x+ logit xs = gbcInit gb+ + gbcLR gb * sum [ RF.predictTree t xs | t <- gbcTrees gb ]+ in VU.generate n (\i -> sigmoid (logit (rowList x i)))++-- | [日本語]: クラス予測 (閾値 0.5)。+-- [English]: Predicts the class (threshold 0.5).+predictGBC :: GBClassifier -> LA.Matrix Double -> VU.Vector Int+predictGBC gb x =+ VU.map (\p -> if p >= 0.5 then 1 else 0) (predictGBCProbs gb x)
+ src/Hanalyze/Model/HierarchicalCluster.hs view
@@ -0,0 +1,245 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE BangPatterns #-}+-- |+-- Module : Hanalyze.Model.HierarchicalCluster+-- Description : 凝集型階層クラスタリング (Agglomerative Hierarchical Clustering)+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- [日本語]: 凝集型階層クラスタリング (Agglomerative Hierarchical Clustering)。+--+-- Lance-Williams update formula による O(n²) アルゴリズム。+-- 各ステップで最近接クラスタ対をマージし、 新クラスタへの距離を再計算する。+--+-- 対応 linkage:+--+-- - 'Single' : d(i∪j, k) = min(d(i,k), d(j,k))+-- - 'Complete' : d(i∪j, k) = max(d(i,k), d(j,k))+-- - 'Average' : (|i|·d(i,k) + |j|·d(j,k)) / (|i|+|j|)+-- - 'Ward' : Lance-Williams 係数で分散最小化+--+-- 距離は Euclidean のみサポート (X の各行をサンプルとして二乗ユークリッド距離)。+--+-- [English]: Agglomerative Hierarchical Clustering.+--+-- An O(n²) algorithm using the Lance-Williams update formula. At each step,+-- the nearest pair of clusters is merged and the distances to the new+-- cluster are recomputed.+--+-- Supported linkages:+--+-- - 'Single' : d(i∪j, k) = min(d(i,k), d(j,k))+-- - 'Complete' : d(i∪j, k) = max(d(i,k), d(j,k))+-- - 'Average' : (|i|·d(i,k) + |j|·d(j,k)) / (|i|+|j|)+-- - 'Ward' : minimizes variance via the Lance-Williams coefficients+--+-- Only Euclidean distance is supported (squared Euclidean distance treating+-- each row of X as a sample).+module Hanalyze.Model.HierarchicalCluster+ ( Linkage (..)+ , HClusterFit (..)+ , fitHierarchical+ , cutTree+ ) where++import qualified Data.Vector as V+import qualified Data.Vector.Mutable as MV+import qualified Data.Vector.Unboxed.Mutable as MU+import qualified Numeric.LinearAlgebra as LA+import Control.Monad (forM_, when)+import Control.Monad.ST (runST)+import Data.STRef (newSTRef, readSTRef, writeSTRef,+ modifySTRef')+import Data.List (foldl')++-- ===========================================================================+-- 型+-- ===========================================================================++data Linkage = Single | Complete | Average | Ward+ deriving (Show, Eq)++data HClusterFit = HClusterFit+ { hcMerges :: ![(Int, Int)] -- ^ [日本語]: マージ列 (n-1 個)。 ID は 0..n-1 が元サンプル、+ -- 以降 n, n+1, ... が新クラスタ。 [English]: The+ -- merge sequence (n-1 entries). IDs 0..n-1 are the+ -- original samples; n, n+1, ... onward are new clusters.+ , hcHeights :: ![Double] -- ^ [日本語]: マージ時点での距離 (linkage に応じた値)。+ -- [English]: The distance at the time of each merge+ -- (a value dependent on the linkage).+ , hcLinkage :: !Linkage+ , hcNumOriginals :: !Int -- ^ [日本語]: n_samples。 [English]: n_samples.+ } deriving (Show)++-- ===========================================================================+-- fit+-- ===========================================================================++-- | [日本語]: 階層クラスタリングを fit する。 X は n × p 行列、 各行が 1 サンプル。+-- [English]: Fits hierarchical clustering. X is an n × p matrix, with each+-- row a sample.+fitHierarchical :: Linkage -> LA.Matrix Double -> HClusterFit+fitHierarchical link xs =+ let n = LA.rows xs+ d0 = initialDistance link xs+ in agglomerate link n d0++-- | [日本語]: 樹形図を K クラスタに切り、 各サンプルのクラスタ ID を返す。+-- K = 1 → 全サンプル ID 0; K = n → 全サンプル別 ID。+-- [English]: Cuts the dendrogram into K clusters and returns each sample's+-- cluster ID. K = 1 → all samples get ID 0; K = n → every sample gets a+-- distinct ID.+cutTree :: HClusterFit -> Int -> V.Vector Int+cutTree fit k+ | k <= 0 = V.replicate (hcNumOriginals fit) 0+ | k >= n = V.generate n id+ | otherwise =+ let nMerges = n - k -- K クラスタにするには n-K 回マージを適用+ mergesUsed = take nMerges (hcMerges fit)+ -- union-find 風: parent[i] = root cluster representative+ parents = runST $ do+ arr <- MV.replicate (2 * n) (-1 :: Int)+ forM_ [0 .. n - 1] $ \i -> MV.write arr i i+ forM_ (zip [n ..] mergesUsed) $ \(newId, (a, b)) -> do+ ra <- findRoot arr a+ rb <- findRoot arr b+ MV.write arr ra newId+ MV.write arr rb newId+ MV.write arr newId newId+ V.generateM n (findRoot arr)+ uniqRoots = foldr (\r acc -> if r `elem` acc then acc else r:acc) [] (V.toList parents)+ roots = zip uniqRoots [0 ..]+ lookupId r = case lookup r roots of+ Just i -> i+ Nothing -> 0+ in V.map lookupId parents+ where+ n = hcNumOriginals fit+ findRoot arr i = do+ p <- MV.read arr i+ if p == i then pure i else findRoot arr p++-- ===========================================================================+-- 内部: 距離行列の構築+-- ===========================================================================++-- | [日本語]: 初期距離行列 (n × n)。 二乗ユークリッド距離。+-- Ward は二乗距離を使うのが定義どおり。 他 linkage は √ を取って通常距離にする。+-- [English]: The initial distance matrix (n × n). Squared Euclidean+-- distance. Ward uses the squared distance as defined; other linkages+-- take the √ to get the ordinary distance.+initialDistance :: Linkage -> LA.Matrix Double -> LA.Matrix Double+initialDistance link xs =+ let n = LA.rows xs+ sqDist i j =+ let r = LA.flatten (xs LA.? [i]) - LA.flatten (xs LA.? [j])+ in LA.sumElements (r * r)+ raw = LA.build (n, n)+ (\i j -> sqDist (round i) (round j) :: Double)+ in case link of+ Ward -> raw -- squared+ _ -> LA.cmap sqrt raw++-- ===========================================================================+-- 内部: 凝集アルゴリズム+-- ===========================================================================++agglomerate :: Linkage -> Int -> LA.Matrix Double -> HClusterFit+agglomerate link n d0 = runST $ do+ -- Phase 17.2 改善:+ -- * 距離行列を MU (Unboxed Mutable Vector Double) で flat 配列に+ -- * active set を Unboxed Mutable Vector Int でコンパクトに保持+ -- (毎ステップ tail 切詰めの代わりに、 in-place で a,b 位置を最後と入替え)+ -- * unsafeRead / unsafeWrite で境界チェック排除+ -- * inner loop の STRef 更新を local accumulator (Int * 2 + Double) で減らす+ let !totalIds = 2 * n - 1+ dist <- MU.unsafeNew (totalIds * totalIds)+ -- 初期化: ∞+ forM_ [0 .. totalIds * totalIds - 1] $ \k -> MU.unsafeWrite dist k (1/0 :: Double)+ sizes <- MU.replicate totalIds (1 :: Int)+ forM_ [0 .. n - 1] $ \i ->+ forM_ [0 .. n - 1] $ \j ->+ when (i /= j) $+ MU.unsafeWrite dist (i * totalIds + j) (LA.atIndex d0 (i, j))+ -- active: 先頭 `activeLen` 要素が active な ID+ active <- MU.unsafeNew totalIds+ forM_ [0 .. n - 1] $ \i -> MU.unsafeWrite active i i+ activeLenRef <- newSTRef n+ mergesRef <- newSTRef ([] :: [(Int, Int)])+ heightsRef <- newSTRef ([] :: [Double])+ forM_ [0 .. n - 2] $ \step -> do+ let !nextId = n + step+ !alen <- readSTRef activeLenRef+ -- find argmin。 active[0 .. alen-1] のペアを直接走査+ bestRef <- newSTRef ((-1) :: Int, (-1) :: Int, 1/0 :: Double, (-1) :: Int, (-1) :: Int)+ -- (a, b, bestDist, posA, posB) posA/posB は active 内の位置+ forM_ [0 .. alen - 2] $ \pi_ -> do+ !i <- MU.unsafeRead active pi_+ forM_ [pi_ + 1 .. alen - 1] $ \pj -> do+ !j <- MU.unsafeRead active pj+ !d <- MU.unsafeRead dist (i * totalIds + j)+ (_, _, !best, _, _) <- readSTRef bestRef+ when (d < best) $ writeSTRef bestRef (i, j, d, pi_, pj)+ (!a, !b, !h, !pa, !pb) <- readSTRef bestRef+ modifySTRef' mergesRef ((a, b) :)+ modifySTRef' heightsRef ((reportHeight link h) :)+ !na <- MU.unsafeRead sizes a+ !nb <- MU.unsafeRead sizes b+ MU.unsafeWrite sizes nextId (na + nb)+ -- active から a, b を削除し nextId を追加: pb を末尾と swap で除去、+ -- 同様に pa を新末尾と swap、 alen 減 2、 末尾に nextId を入れて alen 増 1+ -- ※ pa < pb 不変 (内側 loop が pj > pi)+ !lastPos <- pure (alen - 1)+ !valLast <- MU.unsafeRead active lastPos+ MU.unsafeWrite active pb valLast+ !secondLast <- pure (alen - 2)+ !valSecond <- MU.unsafeRead active secondLast+ -- pa の位置は pb と入替えで動いていない (pa < pb なので)+ MU.unsafeWrite active pa valSecond+ MU.unsafeWrite active secondLast nextId+ writeSTRef activeLenRef (alen - 1) -- 2 削除 + 1 追加 = -1+ !alenNew <- readSTRef activeLenRef+ -- Lance-Williams update: active[0 .. alenNew - 1] (末尾は nextId)+ let !nextRow = nextId * totalIds+ forM_ [0 .. alenNew - 2] $ \pk -> do+ !k <- MU.unsafeRead active pk+ !dak <- MU.unsafeRead dist (a * totalIds + k)+ !dbk <- MU.unsafeRead dist (b * totalIds + k)+ !nk <- MU.unsafeRead sizes k+ let !dNew = lanceWilliams link (na, nb, nk) dak dbk h+ MU.unsafeWrite dist (nextRow + k) dNew+ MU.unsafeWrite dist (k * totalIds + nextId) dNew+ merges <- reverse <$> readSTRef mergesRef+ heights <- reverse <$> readSTRef heightsRef+ pure HClusterFit+ { hcMerges = merges+ , hcHeights = heights+ , hcLinkage = link+ , hcNumOriginals = n+ }+ where+ reportHeight Ward h = sqrt (max 0 h)+ reportHeight _ h = h++-- | [日本語]: Lance-Williams recurrence:+-- d(i∪j, k) = α_i d(i,k) + α_j d(j,k) + β d(i,j) + γ |d(i,k) − d(j,k)|+-- [English]: The Lance-Williams recurrence:+-- d(i∪j, k) = α_i d(i,k) + α_j d(j,k) + β d(i,j) + γ |d(i,k) − d(j,k)|+lanceWilliams :: Linkage+ -> (Int, Int, Int) -- sizes (n_a, n_b, n_k)+ -> Double -- d(a, k)+ -> Double -- d(b, k)+ -> Double -- d(a, b)+ -> Double+lanceWilliams link (na, nb, nk) dak dbk dab =+ case link of+ Single -> min dak dbk+ Complete -> max dak dbk+ Average ->+ let naD = fromIntegral na; nbD = fromIntegral nb+ in (naD * dak + nbD * dbk) / (naD + nbD)+ Ward ->+ let naD = fromIntegral na; nbD = fromIntegral nb+ nkD = fromIntegral nk+ tot = naD + nbD + nkD+ in ((naD + nkD) * dak + (nbD + nkD) * dbk - nkD * dab) / tot
+ src/Hanalyze/Model/KNN.hs view
@@ -0,0 +1,147 @@+{-# LANGUAGE BangPatterns #-}+-- |+-- Module : Hanalyze.Model.KNN+-- Description : k近傍法 (k-Nearest Neighbours、 回帰 + 分類、 brute force ユークリッド距離)+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- [日本語]: k-Nearest Neighbours (回帰 + 分類、 brute force ユークリッド距離).+--+-- @+-- import qualified Hanalyze.Model.KNN as KNN+-- let knnR = KNN.fitKNNR 5 xTrain yTrain+-- yR = KNN.predictKNNR knnR xTest+-- @+--+-- /Complexity/: O(n_test · n_train · d)。 KD-tree は scope 外。+--+-- [English]: k-Nearest Neighbours (regression + classification, brute-force+-- Euclidean distance).+--+-- @+-- import qualified Hanalyze.Model.KNN as KNN+-- let knnR = KNN.fitKNNR 5 xTrain yTrain+-- yR = KNN.predictKNNR knnR xTest+-- @+--+-- /Complexity/: O(n_test · n_train · d). A KD-tree is out of scope.+module Hanalyze.Model.KNN+ ( KNNRegressor (..)+ , KNNClassifier (..)+ , fitKNNR+ , fitKNNC+ , predictKNNR+ , predictKNNC+ , predictKNNCProbs+ ) where++import qualified Data.Vector.Unboxed as VU+import qualified Numeric.LinearAlgebra as LA+import qualified Data.Map.Strict as Map+import Data.List (foldl', sortBy, nub, sort)+import Data.Ord (comparing)+import Data.Text (Text)++-- ---------------------------------------------------------------------------+-- Types+-- ---------------------------------------------------------------------------++data KNNRegressor = KNNRegressor+ { knnRK :: !Int+ , knnRX :: !(LA.Matrix Double)+ , knnRY :: !(VU.Vector Double)+ } deriving (Show)++data KNNClassifier = KNNClassifier+ { knnCK :: !Int+ , knnCX :: !(LA.Matrix Double)+ , knnCY :: !(VU.Vector Int)+ , knnCClasses :: ![Int]+ , knnCClassNames :: ![Text] -- ^ [日本語]: クラス名 (df|-> が levels 注入・空=数値表示)。 [English]: Class names (injected as levels by df|->; empty = displayed numerically).+ } deriving (Show)++-- ---------------------------------------------------------------------------+-- Fit+-- ---------------------------------------------------------------------------++fitKNNR :: Int -> LA.Matrix Double -> VU.Vector Double -> KNNRegressor+fitKNNR k x y = KNNRegressor k x y++fitKNNC :: Int -> LA.Matrix Double -> VU.Vector Int -> KNNClassifier+fitKNNC k x y = KNNClassifier+ { knnCK = k+ , knnCX = x+ , knnCY = y+ , knnCClasses = sort (nub (VU.toList y))+ , knnCClassNames = [] -- df|-> 経路が reqLabelWithLevels で後から注入。+ }++-- ---------------------------------------------------------------------------+-- Predict helpers+-- ---------------------------------------------------------------------------++rowVec :: LA.Matrix Double -> Int -> LA.Vector Double+rowVec x i = LA.flatten (x LA.? [i])++-- | [日本語]: クエリ点に対し、 訓練データ各行までの距離 (二乗) と元 index のペア+-- を返す。+-- [English]: For a query point, returns pairs of the (squared) distance to+-- each training-data row and the original index.+distancesSq :: LA.Matrix Double -> LA.Vector Double -> [(Int, Double)]+distancesSq xTrain q =+ let !n = LA.rows xTrain+ in [ (i, let v = rowVec xTrain i - q in LA.dot v v)+ | i <- [0 .. n - 1] ]++kNearest :: Int -> LA.Matrix Double -> LA.Vector Double -> [Int]+kNearest k xTrain q =+ let ds = sortBy (comparing snd) (distancesSq xTrain q)+ in map fst (take k ds)++-- ---------------------------------------------------------------------------+-- Predict (regression)+-- ---------------------------------------------------------------------------++predictKNNR :: KNNRegressor -> LA.Matrix Double -> VU.Vector Double+predictKNNR knn xTest =+ let !nT = LA.rows xTest+ !k = knnRK knn+ !xT = knnRX knn+ !yT = knnRY knn+ pred1 i =+ let q = rowVec xTest i+ ids = kNearest k xT q+ ys = [ yT VU.! j | j <- ids ]+ in sum ys / fromIntegral (length ys)+ in VU.generate nT pred1++-- ---------------------------------------------------------------------------+-- Predict (classification)+-- ---------------------------------------------------------------------------++predictKNNCProbs :: KNNClassifier+ -> LA.Matrix Double+ -> [Map.Map Int Double]+predictKNNCProbs knn xTest =+ let !nT = LA.rows xTest+ !k = knnCK knn+ !xT = knnCX knn+ !yT = knnCY knn+ counts1 i =+ let q = rowVec xTest i+ ids = kNearest k xT q+ cs = [ yT VU.! j | j <- ids ]+ !nk = fromIntegral (length cs) :: Double+ mp = foldl' (\m c -> Map.insertWith (+) c 1 m)+ Map.empty cs+ in Map.map (/ nk) mp+ in [ counts1 i | i <- [0 .. nT - 1] ]++predictKNNC :: KNNClassifier -> LA.Matrix Double -> VU.Vector Int+predictKNNC knn xTest =+ let probs = predictKNNCProbs knn xTest+ majority m =+ case sortBy (flip (comparing snd)) (Map.toList m) of+ ((c, _) : _) -> c+ [] -> 0+ in VU.fromList (map majority probs)
+ src/Hanalyze/Model/Kernel.hs view
@@ -0,0 +1,325 @@+{-# LANGUAGE StrictData #-}+{-# LANGUAGE BangPatterns #-}+{-# LANGUAGE OverloadedStrings #-}+-- |+-- Module : Hanalyze.Model.Kernel+-- Description : GP/SVM/カーネル法で共通のカーネル語彙 (RBF/Matern52/Periodic/Linear/Poly)+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- [日本語]: 共有カーネル語彙 (GP / SVM / カーネル法で共通)。 'Model.GP' から分離した。+--+-- GP 族の定常/内積カーネル ('RBF' / 'Matern52' / 'Periodic' / 'Linear' / 'Poly') と+-- そのハイパーパラメータ 'KernelParams' (ℓ / σ_f² / period / ARD per-dim ℓ) を集約する。+-- @GPParams@ (= 'KernelParams' + 観測ノイズ σ_n²) に依存しないので、 SVM 等+-- ノイズを持たないカーネル法はこのモジュールだけを import すればよい+-- ('Model.GP' を import しない)。+--+-- 評価関数:+--+-- - 'kernelFn' — 1D 入力の @k(x, x')@。+-- - 'buildKernelMatrix' — 1D の Gram 行列 @K(xs, xs')@。+-- - 'applyKernel' — 二乗距離行列 → カーネル行列 (距離カーネル専用)。+-- - 'kernelOfParams' — 固定パラメータの @s ↦ k(s)@ (距離カーネル専用・INLINE)。+-- - 'ardScaleXY' — ARD 列スケーリング。+-- - 'buildKernelMatrixMV' — 多入力 Gram 行列 (全カーネル)。+-- - 'kEvalMV' — 多入力の点対点評価 @k(a, b)@ (全カーネル・SVM 等の汎用経路)。+--+-- 距離カーネル (RBF/Matern52/Periodic) は二乗距離から、 内積カーネル+-- (Linear/Poly) は内積から評価する。 'applyKernel' / 'kernelOfParams' は距離専用で、+-- 内積カーネルを渡すと error (multi-input gram は 'buildKernelMatrixMV' が内積経路へ+-- 分岐するためそこには到達しない)。+--+-- [English]: Shared kernel vocabulary (common to GP \/ SVM \/ kernel+-- methods). Split out from 'Model.GP'.+--+-- Gathers the GP family's stationary\/dot-product kernels ('RBF' \/+-- 'Matern52' \/ 'Periodic' \/ 'Linear' \/ 'Poly') and their hyperparameters+-- 'KernelParams' (ℓ \/ σ_f² \/ period \/ per-dim ARD ℓ). This module does+-- not depend on @GPParams@ (= 'KernelParams' + observation noise σ_n²), so+-- noise-free kernel methods such as SVM only need to import this module+-- (and not 'Model.GP').+--+-- Evaluation functions:+--+-- - 'kernelFn' — @k(x, x')@ for 1D inputs.+-- - 'buildKernelMatrix' — the 1D Gram matrix @K(xs, xs')@.+-- - 'applyKernel' — squared-distance matrix → kernel matrix+-- (distance kernels only).+-- - 'kernelOfParams' — @s ↦ k(s)@ for fixed parameters (distance+-- kernels only, INLINE).+-- - 'ardScaleXY' — ARD column scaling.+-- - 'buildKernelMatrixMV' — multi-input Gram matrix (all kernels).+-- - 'kEvalMV' — point-to-point multi-input evaluation+-- @k(a, b)@ (all kernels; the generic path used by SVM etc.).+--+-- Distance kernels (RBF\/Matern52\/Periodic) are evaluated from squared+-- distance; dot-product kernels (Linear\/Poly) are evaluated from the dot+-- product. 'applyKernel' \/ 'kernelOfParams' are distance-only and error+-- if given a dot-product kernel (the multi-input gram path never reaches+-- them, since 'buildKernelMatrixMV' branches to the dot-product path+-- first).+module Hanalyze.Model.Kernel+ ( -- * カーネル型+ Kernel (..)+ , kernelName+ -- * カーネルハイパーパラメータ+ , KernelParams (..)+ , defaultKernelParams+ -- * 評価+ , kernelFn+ , buildKernelMatrix+ , applyKernel+ , kernelOfParams+ , ardScaleXY+ , buildKernelMatrixMV+ , kEvalMV+ ) where++import Data.Text (Text)+import qualified Data.Text as T+import qualified Numeric.LinearAlgebra as LA+import qualified Hanalyze.Stat.KernelDist as KD+import qualified Data.Vector.Storable as VS+import qualified Data.Vector.Storable.Mutable as VSM+import Control.Monad.ST (runST)++-- ---------------------------------------------------------------------------+-- 型+-- ---------------------------------------------------------------------------++-- | [日本語]: GP / SVM 族のカーネル種別。+-- [English]: Kernel variants for the GP\/SVM family.+data Kernel+ = RBF+ -- ^ Squared exponential: @k(x,x') = σ_f² exp(−r²/(2ℓ²))@.+ -- Best for smooth functions; the most commonly used kernel.+ | Matern52+ -- ^ Matérn 5/2: @k(x,x') = σ_f²(1+√5 r/ℓ+5r²/(3ℓ²)) exp(−√5 r/ℓ)@.+ -- Slightly rougher than RBF; common in physical systems.+ | Periodic+ -- ^ Periodic: @k(x,x') = σ_f² exp(−2 sin²(π r/p)/ℓ²)@.+ -- For periodic patterns; set 'kpPeriod' appropriately.+ | Linear+ -- ^ Linear (dot-product): @k(x,x') = σ_f² (x·x')@. A non-stationary+ -- kernel; with SVM gives a linear decision boundary.+ | Poly !Int+ -- ^ Polynomial of degree @d@: @k(x,x') = (γ (x·x') + 1)^d@ with+ -- @γ = 1/(2ℓ²)@ (shared with the SVM γ convention). A+ -- non-stationary kernel.+ deriving (Show, Eq)++-- | Display name of a kernel.+kernelName :: Kernel -> Text+kernelName RBF = "RBF"+kernelName Matern52 = "Mat\xe9rn 5/2"+kernelName Periodic = "Periodic"+kernelName Linear = "Linear"+kernelName (Poly d) = "Poly(" <> T.pack (show d) <> ")"++-- | [日本語]: カーネルハイパーパラメータ (観測ノイズ σ_n² は含まない)。+-- [English]: Kernel hyperparameters (does not include the observation+-- noise σ_n²).+data KernelParams = KernelParams+ { kpLengthScale :: Double+ -- ^ Isotropic length scale @ℓ@; larger means smoother. Used unless+ -- 'kpLengthScales' is 'Just' (= ARD), in which case the per-dim+ -- vector overrides this for multi-input kernel evaluation.+ , kpSignalVar :: Double+ -- ^ Signal variance @σ_f²@; the variability of the function values.+ , kpPeriod :: Double+ -- ^ Period @p@ (only used by the @Periodic@ kernel).+ , kpLengthScales :: Maybe (LA.Vector Double)+ -- ^ Per-dim length scales for ARD (Automatic Relevance+ -- Determination). When 'Just' v, the multi-input kernel uses+ -- @D_ARD[i,j] = Σ_d (X[i,d] − X'[j,d])² / ℓ_d²@ instead of the+ -- isotropic distance / ℓ². Has no effect on the 1D 'kernelFn'+ -- path. 'Nothing' = isotropic (default).+ } deriving (Show)++-- | Default kernel hyperparameters: @ℓ = σ_f² = p = 1@, isotropic.+defaultKernelParams :: KernelParams+defaultKernelParams = KernelParams 1.0 1.0 1.0 Nothing++-- ---------------------------------------------------------------------------+-- 1D 評価+-- ---------------------------------------------------------------------------++-- | Evaluate the kernel function @k(x, x')@ for scalar inputs.+kernelFn :: Kernel -> KernelParams -> Double -> Double -> Double+kernelFn RBF p x x' =+ let d = x - x'+ l = kpLengthScale p+ in kpSignalVar p * exp (-(d * d) / (2 * l * l))+kernelFn Matern52 p x x' =+ let d = abs (x - x')+ l = kpLengthScale p+ s = sqrt 5 * d / l+ in kpSignalVar p * (1 + s + s * s / 3) * exp (-s)+kernelFn Periodic p x x' =+ let d = abs (x - x')+ l = kpLengthScale p+ s = sin (pi * d / kpPeriod p)+ in kpSignalVar p * exp (-2 * s * s / (l * l))+kernelFn Linear p x x' =+ -- 内積カーネル: 1D では x·x' = x*x'。+ kpSignalVar p * (x * x')+kernelFn (Poly d) p x x' =+ -- (γ x·x' + 1)^d, γ = 1/(2ℓ²)。1D では x·x' = x*x'。+ let l = kpLengthScale p+ g = 1 / (2 * l * l)+ in (g * (x * x') + 1) ^^ d++-- | Build the kernel matrix @K(xs, xs')@ of shape @|xs| × |xs'|@.+--+-- Fills a flat 'Storable.Vector' via @runST + MVector@ instead of+-- materialising the @|xs|·|xs'|@ lazy @[Double]@ list (one allocation per+-- kernel call). 'kernelFn' itself is unchanged so 'Periodic'+-- (signed-difference dependent) keeps working.+buildKernelMatrix :: Kernel -> KernelParams -> [Double] -> [Double] -> LA.Matrix Double+buildKernelMatrix ker p xs xs' =+ let xv = VS.fromList xs+ yv = VS.fromList xs'+ n = VS.length xv+ m = VS.length yv+ out = runST $ do+ v <- VSM.unsafeNew (n * m)+ let go !i !j+ | i >= n = pure ()+ | j >= m = go (i + 1) 0+ | otherwise = do+ let xi = VS.unsafeIndex xv i+ yj = VS.unsafeIndex yv j+ VSM.unsafeWrite v (i * m + j) (kernelFn ker p xi yj)+ go i (j + 1)+ go 0 0+ VS.unsafeFreeze v+ in LA.reshape m out++-- ---------------------------------------------------------------------------+-- 多入力 (multivariate) 評価+-- ---------------------------------------------------------------------------++-- | [日本語]: 二乗距離行列 (@m × n@) にカーネル関数を適用する。+-- 距離カーネル (RBF/Matern52/Periodic) 専用。 内積カーネル (Linear/Poly) は+-- 二乗距離から復元できないため error (multi-input gram は 'buildKernelMatrixMV'+-- が内積経路へ分岐するためここには到達しない)。+-- [English]: Apply the kernel function to an @m × n@ matrix of squared+-- distances. Distance kernels (RBF\/Matern52\/Periodic) only.+-- Dot-product kernels (Linear\/Poly) cannot be recovered from the+-- squared distance and error out (the multi-input gram path never+-- reaches here, since 'buildKernelMatrixMV' branches to the+-- dot-product path first).+applyKernel :: Kernel -> KernelParams -> LA.Matrix Double -> LA.Matrix Double+applyKernel RBF p d2 =+ let l2 = kpLengthScale p ** 2+ sf = kpSignalVar p+ in KD.mapMatrix (\s -> sf * exp (- s / (2 * l2))) d2+applyKernel Matern52 p d2 =+ let l = kpLengthScale p+ sf = kpSignalVar p+ in KD.mapMatrix (\s -> let r = sqrt (max 0 s)+ u = sqrt 5 * r / l+ in sf * (1 + u + u * u / 3) * exp (- u)) d2+applyKernel Periodic p d2 =+ let l = kpLengthScale p+ sf = kpSignalVar p+ pr = kpPeriod p+ in KD.mapMatrix (\s -> let r = sqrt (max 0 s)+ ss = sin (pi * r / pr)+ in sf * exp (- 2 * ss * ss / (l * l))) d2+applyKernel Linear _ _ = error "applyKernel: Linear は内積カーネル。buildKernelMatrixMV/kEvalMV を使うこと"+applyKernel (Poly _) _ _ = error "applyKernel: Poly は内積カーネル。buildKernelMatrixMV/kEvalMV を使うこと"++-- | Apply ARD scaling to (X, X') if 'kpLengthScales' is 'Just'. Returns+-- the (possibly rescaled) matrices and a 'KernelParams' with @ℓ = 1@ so+-- that 'applyKernel' divides by 1 (the per-dim ℓ_d already absorbed into+-- the column scaling). 'Nothing' = isotropic, returns inputs and params+-- unchanged. The 'Periodic' kernel does not support ARD.+ardScaleXY+ :: Kernel -> KernelParams -> LA.Matrix Double -> LA.Matrix Double+ -> (LA.Matrix Double, LA.Matrix Double, KernelParams)+ardScaleXY Periodic p x y = (x, y, p)+ardScaleXY _ p x y = case kpLengthScales p of+ Nothing -> (x, y, p)+ Just ls ->+ let p_ = LA.cols x+ lsExt = if LA.size ls == p_+ then ls+ else LA.konst (kpLengthScale p) p_ -- safety fallback+ invL = LA.cmap (1 /) lsExt -- 1 / ℓ_d+ scaleCols m = m LA.<> LA.diag invL+ x' = scaleCols x+ y' = scaleCols y+ p' = p { kpLengthScale = 1.0 }+ in (x', y', p')++-- | Build the kernel matrix @K(X, X')@ of shape @|X| × |X'|@ from+-- multi-input matrices. @X@ is @n × p@; @X'@ is @m × p@.+--+-- When 'kpLengthScales' is 'Just', uses ARD: each input dimension is+-- scaled by @1 / ℓ_d@ before computing pairwise squared distances.+buildKernelMatrixMV+ :: Kernel -> KernelParams -> LA.Matrix Double -> LA.Matrix Double+ -> LA.Matrix Double+buildKernelMatrixMV Linear p x x' =+ -- 内積カーネル: K = σ_f² X X'ᵀ (距離経路を通さない)。+ LA.scale (kpSignalVar p) (x LA.<> LA.tr x')+buildKernelMatrixMV (Poly d) p x x' =+ -- (γ X X'ᵀ + 1)^d, γ = 1/(2ℓ²)。+ let l = kpLengthScale p+ g = 1 / (2 * l * l)+ in LA.cmap (\ip -> (g * ip + 1) ^^ d) (x LA.<> LA.tr x')+buildKernelMatrixMV ker p x x' =+ let (xs, ys, p') = ardScaleXY ker p x x'+ in applyKernel ker p' (KD.pairwiseSqDistXY xs ys)++-- | [日本語]: 多入力カーネル評価 @k(a, b)@ (全カーネル対応・SVM 等の汎用経路)。+-- 距離カーネル (RBF/Matern52/Periodic) は二乗距離、 内積カーネル (Linear/Poly)+-- は内積から評価する。+-- [English]: Multi-input kernel evaluation @k(a, b)@ (supports all+-- kernels; the generic path used by SVM etc.). Distance kernels+-- (RBF\/Matern52\/Periodic) are evaluated from squared distance,+-- dot-product kernels (Linear\/Poly) from the dot product.+kEvalMV :: Kernel -> KernelParams -> LA.Vector Double -> LA.Vector Double -> Double+kEvalMV Linear p a b = kpSignalVar p * (a LA.<.> b)+kEvalMV (Poly d) p a b =+ let l = kpLengthScale p+ g = 1 / (2 * l * l)+ in (g * (a LA.<.> b) + 1) ^^ d+kEvalMV ker p a b =+ let d = a - b+ in kernelOfParams ker p (d LA.<.> d) -- 距離カーネル: s = ‖a−b‖²++-- | [日本語]: 固定パラメータ集合に対する特殊化カーネル関数。 GHC が+-- 'Model.GP' の @mkNoiseKernelFromD2@ 内側ループに密にインライン化できる+-- 単相 @Double -> Double@ を返す。 距離カーネル専用。+-- [English]: Specialized kernel function for a fixed parameter set,+-- returning a monomorphic @Double -> Double@ that GHC can inline tightly+-- into the @mkNoiseKernelFromD2@ inner loop (in 'Model.GP'). Distance+-- kernels only.+{-# INLINE kernelOfParams #-}+kernelOfParams :: Kernel -> KernelParams -> (Double -> Double)+kernelOfParams RBF p =+ let !l2 = kpLengthScale p ** 2+ !sf = kpSignalVar p+ !inv2L2 = 1 / (2 * l2)+ in \s -> sf * exp (- s * inv2L2)+kernelOfParams Matern52 p =+ let !l = kpLengthScale p+ !sf = kpSignalVar p+ !invL = sqrt 5 / l+ in \s -> let r = sqrt (max 0 s)+ u = invL * r+ in sf * (1 + u + u * u / 3) * exp (- u)+kernelOfParams Periodic p =+ let !l = kpLengthScale p+ !sf = kpSignalVar p+ !pr = kpPeriod p+ !invL2 = 1 / (l * l)+ !invPr = pi / pr+ in \s -> let r = sqrt (max 0 s)+ ss = sin (invPr * r)+ in sf * exp (- 2 * ss * ss * invL2)+kernelOfParams Linear _ = error "kernelOfParams: Linear は内積カーネル。kEvalMV を使うこと"+kernelOfParams (Poly _) _ = error "kernelOfParams: Poly は内積カーネル。kEvalMV を使うこと"
+ src/Hanalyze/Model/KernelRegression.hs view
@@ -0,0 +1,507 @@+{-# LANGUAGE StrictData #-}+{-# LANGUAGE OverloadedStrings #-}+-- |+-- Module : Hanalyze.Model.KernelRegression+-- Description : カーネル回帰 (Nadaraya-Watson / kernel ridge regression)+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- [日本語]: Kernel regression — Nadaraya-Watson and kernel ridge regression.+--+-- - 'Kernel' — RBF / Matérn / triangular / Epanechnikov kernel+-- functions.+-- - 'nwRegression' — Nadaraya-Watson (kernel-weighted moving average).+-- - 'kernelRidge' — kernel ridge regression+-- @ŷ(x*) = k(x*)ᵀ (K + λI)⁻¹ y@.+--+-- Both are non-parametric smooth nonlinear regressors. Unlike 'Hanalyze.Model.GP',+-- they do not produce uncertainty estimates.+--+-- NB: この 'Kernel' は回帰スムージング用 (Gaussian/Epanechnikov/…)。+-- GP/SVM 族の共有カーネル (RBF/Matérn5/2/Periodic/Linear/Poly) は別モジュール+-- 'Hanalyze.Model.Kernel' として分離されている。+--+-- [English]: Kernel regression — Nadaraya-Watson and kernel ridge+-- regression.+--+-- - 'Kernel' — RBF / Matérn / triangular / Epanechnikov kernel+-- functions.+-- - 'nwRegression' — Nadaraya-Watson (kernel-weighted moving average).+-- - 'kernelRidge' — kernel ridge regression+-- @ŷ(x*) = k(x*)ᵀ (K + λI)⁻¹ y@.+--+-- Both are non-parametric smooth nonlinear regressors. Unlike+-- 'Hanalyze.Model.GP', they do not produce uncertainty estimates.+--+-- NB: this 'Kernel' is for regression smoothing (Gaussian \/ Epanechnikov \/+-- …). The shared kernel family used by the GP\/SVM group (RBF \/ Matérn5\/2 \/+-- Periodic \/ Linear \/ Poly) has been split out into a separate module,+-- 'Hanalyze.Model.Kernel'.+module Hanalyze.Model.KernelRegression+ ( Kernel (..)+ , kernelEval+ , kernelFromSqDist+ , nwRegression+ , nwRegressionMulti+ , KernelRidgeFit (..)+ , kernelRidge+ , predictKernelRidge+ , gridSearchBandwidth+ , autoBandwidthBrent+ -- * Multi-output (1D input, multiple Y columns)+ , KernelRidgeFitMulti (..)+ , kernelRidgeMulti+ , predictKernelRidgeMulti+ , fittedKernelRidgeMulti+ , r2Multi+ , autoTuneKernelRidgeMulti+ , defaultHGrid+ , defaultLamGrid+ -- * Multi-input (primary API; X is @n × p@, Y is @n × q@)+ , gramMatrixMV+ , gramMatrixMVXY+ , KernelRidgeFitMV (..)+ , kernelRidgeMV+ , predictKernelRidgeMV+ , fittedKernelRidgeMV+ , nwRegressionMV+ ) where++import qualified Data.Vector as V+import qualified Numeric.LinearAlgebra as LA+import qualified Hanalyze.Optim.LineSearch as LS+import qualified Hanalyze.Optim.Common as OC+import qualified Hanalyze.Stat.KernelDist as KD+import qualified Hanalyze.Stat.Cholesky as Chol++-- ---------------------------------------------------------------------------+-- カーネル関数+-- ---------------------------------------------------------------------------++-- | Supported kernels. The bandwidth @h@ is passed separately at the+-- call site.+data Kernel+ = Gaussian -- ^ @exp(-u²/2)@ (= RBF, infinite support).+ | Epanechnikov -- ^ @0.75 (1-u²)@ on @|u| ≤ 1@.+ | Triangular -- ^ @1 - |u|@ on @|u| ≤ 1@.+ | Uniform -- ^ @0.5@ on @|u| ≤ 1@ (coarsest).+ | TriCube -- ^ @(1-|u|³)³@ on @|u| ≤ 1@.+ deriving (Show, Eq)++-- | Evaluate the kernel at scaled squared distance @s = ‖x − x'‖² / h²@.+-- Generalizes 'kernelEval' to multivariate inputs: every supported+-- kernel is radially symmetric, so the kernel value depends only on+-- @‖x − x'‖ / h@.+--+-- For the Gaussian kernel this avoids the redundant @sqrt@; for kernels+-- with bounded support (Epanechnikov / Triangular / Uniform / TriCube)+-- the boundary check uses @s ≤ 1@.+kernelFromSqDist :: Kernel -> Double -> Double+kernelFromSqDist k s = case k of+ Gaussian -> exp (-0.5 * s) / sqrt (2 * pi)+ Epanechnikov -> if s <= 1 then 0.75 * (1 - s) else 0+ Triangular -> if s <= 1 then 1 - sqrt s else 0+ Uniform -> if s <= 1 then 0.5 else 0+ TriCube -> if s <= 1+ then let u = sqrt s+ t = 1 - u * u * u+ in t * t * t+ else 0++-- | Evaluate the kernel at @u = (x - x_i) / h@.+kernelEval :: Kernel -> Double -> Double+kernelEval k u = case k of+ Gaussian -> exp (-0.5 * u * u) / sqrt (2 * pi)+ Epanechnikov -> if abs u <= 1 then 0.75 * (1 - u * u) else 0+ Triangular -> if abs u <= 1 then 1 - abs u else 0+ Uniform -> if abs u <= 1 then 0.5 else 0+ TriCube -> if abs u <= 1+ then let t = 1 - (abs u)^(3::Int)+ in t * t * t+ else 0++-- ---------------------------------------------------------------------------+-- Nadaraya-Watson+-- ---------------------------------------------------------------------------++-- | Single-output Nadaraya-Watson kernel regression.+--+-- @ŷ(x*) = Σᵢ K_h(x* - xᵢ) yᵢ / Σᵢ K_h(x* - xᵢ)@+--+-- Delegates to 'nwRegressionMulti' by promoting @y@ to a one-column+-- matrix.+nwRegression :: Kernel+ -> Double -- ^ Bandwidth @h@ (@> 0@).+ -> V.Vector Double -- ^ Training inputs.+ -> V.Vector Double -- ^ Training targets.+ -> V.Vector Double -- ^ Prediction inputs.+ -> V.Vector Double -- ^ Predictions.+nwRegression kern h xs ys xNew =+ let yMat = LA.asColumn (LA.fromList (V.toList ys))+ mat = nwRegressionMulti kern h xs yMat xNew+ in V.fromList (LA.toList (LA.flatten (mat LA.¿ [0])))++-- | Multi-output Nadaraya-Watson: reuse the same weight matrix across+-- every output column. With @W@ of shape @m × n@ and @Y@ of shape+-- @n × q@, the result is the row-normalized product @W · Y@ of shape+-- @m × q@.+nwRegressionMulti :: Kernel+ -> Double -- ^ Bandwidth @h@.+ -> V.Vector Double -- ^ Training inputs (length @n@).+ -> LA.Matrix Double -- ^ Training response @Y@ (@n × q@).+ -> V.Vector Double -- ^ Prediction inputs (length @m@).+ -> LA.Matrix Double -- ^ Predictions (@m × q@).+nwRegressionMulti kern h xs ys xNew =+ let n = V.length xs+ m = V.length xNew+ q = LA.cols ys+ wMat = LA.fromLists+ [ [ kernelEval kern ((xStar - xi) / h)+ | xi <- V.toList xs ]+ | xStar <- V.toList xNew ] -- (m × n)+ num = wMat LA.<> ys -- (m × q)+ dens = LA.toList (wMat LA.#> LA.konst 1 n)+ rows = [ if d == 0 then replicate q 0+ else [ (num `LA.atIndex` (i, j)) / d | j <- [0 .. q - 1] ]+ | (i, d) <- zip [0 .. m - 1] dens ]+ in LA.fromLists rows++-- ---------------------------------------------------------------------------+-- Kernel Ridge regression+-- ---------------------------------------------------------------------------++-- | Kernel ridge regression fit; carries everything needed to predict.+data KernelRidgeFit = KernelRidgeFit+ { krKernel :: Kernel+ , krH :: Double+ , krLambda :: Double+ , krXs :: V.Vector Double -- ^ Training inputs.+ , krAlpha :: LA.Vector Double -- ^ Solution @α = (K + λI)⁻¹ y@.+ } deriving (Show)++-- | Build the Gram matrix @K_{ij} = K_h(x_i - x_j)@.+gramMatrix :: Kernel -> Double -> V.Vector Double -> LA.Matrix Double+gramMatrix kern h xs =+ let n = V.length xs+ xv = V.toList xs+ in (n LA.>< n)+ [ kernelEval kern ((xi - xj) / h)+ | xi <- xv, xj <- xv ]++-- | Single-output kernel ridge regression. Delegates to+-- 'kernelRidgeMulti' by promoting @y@ to a one-column matrix and taking+-- column 0 of the resulting @α@ matrix.+kernelRidge :: Kernel+ -> Double -- ^ Bandwidth @h@.+ -> Double -- ^ Ridge penalty @λ@.+ -> V.Vector Double -- ^ Training inputs.+ -> V.Vector Double -- ^ Training targets.+ -> KernelRidgeFit+kernelRidge kern h lam xs ys =+ let yMat = LA.asColumn (LA.fromList (V.toList ys))+ mf = kernelRidgeMulti kern h lam xs yMat+ a = LA.flatten (krmAlpha mf LA.¿ [0])+ in KernelRidgeFit kern h lam xs a++-- | Predict at new inputs from a 'KernelRidgeFit'.+predictKernelRidge :: KernelRidgeFit -> V.Vector Double -> V.Vector Double+predictKernelRidge fit xNew =+ V.map predict xNew+ where+ xs = krXs fit+ h = krH fit+ kern = krKernel fit+ alpha = krAlpha fit+ predict xStar =+ let kVec = LA.fromList+ [ kernelEval kern ((xStar - xi) / h)+ | xi <- V.toList xs ]+ in kVec LA.<.> alpha++-- ---------------------------------------------------------------------------+-- Bandwidth selection+-- ---------------------------------------------------------------------------++-- | Pick the bandwidth @h@ by leave-one-out cross-validation. Simple+-- grid search: returns the candidate with the smallest LOO RMSE.+gridSearchBandwidth+ :: Kernel+ -> V.Vector Double -- ^ Training inputs.+ -> V.Vector Double -- ^ Training targets.+ -> [Double] -- ^ Candidate bandwidths.+ -> (Double, Double) -- ^ @(best h, best LOO RMSE)@.+gridSearchBandwidth kern xs ys hs =+ let results = [(h, looErrNW kern xs ys h) | h <- hs]+ best = head [ pair | pair <- results+ , snd pair == minimum (map snd results) ]+ in best++-- | NW LOO-CV loss as a continuous function of @h@; shared with+-- 'autoBandwidthBrent'.+looErrNW :: Kernel -> V.Vector Double -> V.Vector Double -> Double -> Double+looErrNW kern xs ys h =+ let n = V.length xs+ yPred = V.imap+ (\i _ ->+ let xs' = V.ifilter (\j _ -> j /= i) xs+ ys' = V.ifilter (\j _ -> j /= i) ys+ xi = xs V.! i+ pred = nwRegression kern h xs' ys' (V.singleton xi)+ in V.head pred)+ xs+ err = V.zipWith (\y yh -> (y - yh)^(2::Int)) ys yPred+ in sqrt (V.sum err / fromIntegral n)++-- | Continuously optimize the bandwidth @h@ with Brent's method+-- (minimizing the LOO-CV loss). Assumes the bracket @[h_lo, h_hi]@ is+-- unimodal. Avoids enumerating discrete candidates the way+-- 'gridSearchBandwidth' does.+--+-- Returns @(best h, best LOO RMSE)@.+autoBandwidthBrent+ :: Kernel+ -> V.Vector Double -- ^ Training inputs.+ -> V.Vector Double -- ^ Training targets.+ -> Double -- ^ Lower bound @h_lo@.+ -> Double -- ^ Upper bound @h_hi@.+ -> (Double, Double)+autoBandwidthBrent kern xs ys hLo hHi =+ let cfg = LS.defaultBrentConfig { LS.bcMaxIter = 80, LS.bcTol = 1e-6 }+ result = LS.brent cfg (\[h] -> looErrNW kern xs ys h) hLo hHi+ hStar = head (OC.orBest result)+ in (hStar, OC.orValue result)++-- ---------------------------------------------------------------------------+-- 多出力 Kernel Ridge (Phase T2)+-- ---------------------------------------------------------------------------++-- | Multi-output kernel ridge regression. With @Y@ of shape @n × q@,+-- solves each column independently but shares the Gram matrix @K@.+data KernelRidgeFitMulti = KernelRidgeFitMulti+ { krmKernel :: Kernel+ , krmH :: Double+ , krmLambda :: Double+ , krmXs :: V.Vector Double+ , krmAlpha :: LA.Matrix Double -- α (n × q)+ } deriving (Show)++-- | Solve @(K + λI)⁻¹ Y@ once and reuse for every column (fast).+kernelRidgeMulti :: Kernel -> Double -> Double+ -> V.Vector Double -> LA.Matrix Double+ -> KernelRidgeFitMulti+kernelRidgeMulti kern h lam xs ys =+ let n = V.length xs+ kMat = gramMatrix kern h xs+ regK = kMat + LA.scale lam (LA.ident n)+ -- regK is SPD (K is PSD, λI is PD). Use Cholesky-based solve;+ -- jitter retry handles ill-conditioned bandwidths.+ alpha = Chol.cholSolveJitter regK ys+ in KernelRidgeFitMulti kern h lam xs alpha++-- | Predict @Ŷ@ for new inputs from a 'KernelRidgeFitMulti'.+predictKernelRidgeMulti :: KernelRidgeFitMulti -> V.Vector Double+ -> LA.Matrix Double+predictKernelRidgeMulti fit xNew =+ let xs = krmXs fit+ h = krmH fit+ kern = krmKernel fit+ alpha = krmAlpha fit+ kMat = LA.fromLists+ [ [ kernelEval kern ((xStar - xi) / h)+ | xi <- V.toList xs ]+ | xStar <- V.toList xNew ]+ in kMat LA.<> alpha++-- | Fitted values at the training inputs (= @ŷ_train@).+fittedKernelRidgeMulti :: KernelRidgeFitMulti -> LA.Matrix Double+fittedKernelRidgeMulti fit = predictKernelRidgeMulti fit (krmXs fit)++-- | Multi-output R² returned as a length-@q@ vector. @Y@ observed and+-- @Ŷ@ predicted both have shape @n × q@.+r2Multi :: LA.Matrix Double -> LA.Matrix Double -> V.Vector Double+r2Multi ys yhat =+ let n = LA.rows ys+ q = LA.cols ys+ colR2 j =+ let yc = LA.toList (LA.flatten (ys LA.¿ [j]))+ yhc = LA.toList (LA.flatten (yhat LA.¿ [j]))+ mu = sum yc / fromIntegral n+ sst = sum [(y - mu)^(2::Int) | y <- yc]+ sse = sum [(y - p)^(2::Int) | (y, p) <- zip yc yhc]+ in if sst == 0 then 0 else 1 - sse / sst+ in V.fromList [ colR2 j | j <- [0 .. q - 1] ]++-- | [日本語]: @(h, λ)@ の joint grid search を closed-form LOOCV で行う。+-- 各 (h, λ) 候補につき hat 行列の対角を 1 回だけ計算し、 全 q 出力の LOO 残差を+-- 一括評価する。+--+-- 戻り値: (best fit, best h, best λ, best mean LOO MSE)+-- [English]: A joint @(h, λ)@ grid search using the closed-form LOOCV.+-- Computes the hat-matrix diagonal once per (h, λ) candidate and evaluates+-- the LOO residuals for all q outputs in one batch.+--+-- Returns: (best fit, best h, best λ, best mean LOO MSE)+autoTuneKernelRidgeMulti+ :: Kernel+ -> V.Vector Double -- xs (n)+ -> LA.Matrix Double -- ys (n × q)+ -> [Double] -- h candidates+ -> [Double] -- λ candidates+ -> (KernelRidgeFitMulti, Double, Double, Double)+autoTuneKernelRidgeMulti kern xs ys hs lams =+ let n = V.length xs+ q = LA.cols ys+ tot = fromIntegral (n * q) :: Double+ score h lam =+ let kMat = gramMatrix kern h xs+ regK = kMat + LA.scale lam (LA.ident n)+ ainv = LA.inv regK+ hat = kMat LA.<> ainv -- (n × n)+ diagH = LA.takeDiag hat+ yhat = hat LA.<> ys -- (n × q)+ res = ys - yhat -- (n × q)+ -- LOO 残差: r_i / (1 - H_ii)、列方向ブロードキャスト+ denom = LA.cmap (\h_ii -> 1 - h_ii) diagH+ invDenom = LA.cmap (\d -> if abs d < 1e-10 then 0 else 1/d) denom+ scaler = LA.fromColumns (replicate q invDenom)+ looR = res * scaler+ sse = LA.sumElements (looR * looR)+ in sse / tot+ grid = [ (h, lam, score h lam) | h <- hs, lam <- lams ]+ best@(bestH, bestL, bestS) = head [ p | p@(_,_,s) <- grid+ , s == minimum (map (\(_,_,x) -> x) grid) ]+ _ = best+ fit = kernelRidgeMulti kern bestH bestL xs ys+ in (fit, bestH, bestL, bestS)++-- | Log-spaced bandwidth candidates. @defaultHGrid xs@ produces 30+-- candidates spanning the range of @xs@.+defaultHGrid :: V.Vector Double -> [Double]+defaultHGrid xs =+ let xv = V.toList xs+ mn = minimum xv+ mx = maximum xv+ rng = mx - mn+ lo = max 1e-3 (rng / 100)+ hi = max (lo * 10) rng+ n = 30+ lLo = log lo+ lHi = log hi+ step = (lHi - lLo) / fromIntegral (n - 1)+ in [ exp (lLo + fromIntegral i * step) | i <- [0 .. n - 1 :: Int] ]++-- | Log-spaced ridge-penalty candidates (10 values from 1e-6 to 1).+defaultLamGrid :: [Double]+defaultLamGrid =+ let n = 10+ lLo = log 1e-6+ lHi = log 1e0+ step = (lHi - lLo) / fromIntegral (n - 1)+ in [ exp (lLo + fromIntegral i * step) | i <- [0 .. n - 1 :: Int] ]++-- ---------------------------------------------------------------------------+-- Multi-input (multivariate X) API+--+-- These functions take @X@ as an @n × p@ matrix (rows = samples) and use a+-- single shared bandwidth @h@ across every input dimension. Distance+-- matrices are computed via 'Hanalyze.Stat.KernelDist' (BLAS GEMM) and the kernel+-- function is applied element-wise via 'LA.cmap'; no list traversals over+-- the @O(n²)@ pair set.+--+-- For axis-specific bandwidths, scale columns of @X@ by @1 / h_d@ before+-- calling these functions.+-- ---------------------------------------------------------------------------++-- | Multi-input Gram matrix @K[i, j] = κ(‖X[i,:] − X[j,:]‖ / h)@.+gramMatrixMV :: Kernel -> Double -> LA.Matrix Double -> LA.Matrix Double+gramMatrixMV kern h x =+ let h2 = h * h+ d2 = KD.pairwiseSqDist x+ in LA.cmap (\s -> kernelFromSqDist kern (s / h2)) d2++-- | Multi-input cross Gram matrix @K[i, j] = κ(‖X[i,:] − Y[j,:]‖ / h)@.+gramMatrixMVXY+ :: Kernel -> Double+ -> LA.Matrix Double -- ^ Query @X_*@ (@m × p@).+ -> LA.Matrix Double -- ^ Training @X@ (@n × p@).+ -> LA.Matrix Double -- ^ Result (@m × n@).+gramMatrixMVXY kern h xs ts =+ let h2 = h * h+ d2 = KD.pairwiseSqDistXY xs ts+ in LA.cmap (\s -> kernelFromSqDist kern (s / h2)) d2++-- | Multi-input kernel ridge fit. Holds the training matrix and the+-- solution coefficients; @α@ has shape @n × q@.+data KernelRidgeFitMV = KernelRidgeFitMV+ { krmvKernel :: Kernel+ , krmvH :: Double+ , krmvLambda :: Double+ , krmvXs :: LA.Matrix Double -- ^ Training inputs (@n × p@).+ , krmvAlpha :: LA.Matrix Double -- ^ @(K + λI)⁻¹ Y@ (@n × q@).+ } deriving (Show)++-- | Multi-input multi-output kernel ridge regression.+--+-- @α = (K + λI)⁻¹ Y@ with @K = gramMatrixMV kern h X@. Solving once and+-- reusing across the @q@ output columns.+kernelRidgeMV+ :: Kernel+ -> Double -- ^ Bandwidth @h@.+ -> Double -- ^ Ridge penalty @λ@.+ -> LA.Matrix Double -- ^ Training inputs @X@ (@n × p@).+ -> LA.Matrix Double -- ^ Training response @Y@ (@n × q@).+ -> KernelRidgeFitMV+kernelRidgeMV kern h lam x y =+ let n = LA.rows x+ kMat = gramMatrixMV kern h x+ regK = kMat + LA.scale lam (LA.ident n)+ -- SPD: K + λI. Use Cholesky-based solve.+ alpha = Chol.cholSolveJitter regK y+ in KernelRidgeFitMV kern h lam x alpha++-- | Predict @Ŷ = K_* α@ for new query inputs (@m × p@). Output shape is+-- @m × q@.+predictKernelRidgeMV :: KernelRidgeFitMV -> LA.Matrix Double -> LA.Matrix Double+predictKernelRidgeMV fit xNew =+ gramMatrixMVXY (krmvKernel fit) (krmvH fit) xNew (krmvXs fit)+ LA.<> krmvAlpha fit++-- | Fitted values at the training inputs.+fittedKernelRidgeMV :: KernelRidgeFitMV -> LA.Matrix Double+fittedKernelRidgeMV fit = predictKernelRidgeMV fit (krmvXs fit)++-- | Multi-input multi-output Nadaraya-Watson regression.+--+-- @ŷ(x*) = (Σⱼ K_h(x* − xⱼ) yⱼ) / Σⱼ K_h(x* − xⱼ)@, computed for every+-- query row in one pass via @W = K(X_*, X)@ then @W Y / row-sums@.+nwRegressionMV+ :: Kernel+ -> Double -- ^ Bandwidth @h@.+ -> LA.Matrix Double -- ^ Training inputs @X@ (@n × p@).+ -> LA.Matrix Double -- ^ Training response @Y@ (@n × q@).+ -> LA.Matrix Double -- ^ Query inputs @X_*@ (@m × p@).+ -> LA.Matrix Double -- ^ Predictions (@m × q@).+nwRegressionMV kern h xs ys xNew =+ -- P35a (2026-05-07): replace @LA.diag safe LA.<> num@ (m×m dense+ -- diag matrix + GEMM) with broadcast outer product → elementwise.+ --+ -- P35b explored further: fusing the @num@ and @denom@ GEMVs into a+ -- single GEMM via @yAug = [ys | onesN]@ to traverse the 8 MB+ -- weight matrix only once (it exceeds typical L3). It /regressed/+ -- at q=1 (33.8 → 37 ms) because (a) @LA.|||@ allocates a fresh+ -- 8 MB matrix, and (b) BLAS GEMM with k=2 RHS columns has higher+ -- block-tiling overhead than two GEMV calls. For q ≫ 1 the fusion+ -- would win, but the bench is q=1 so the unfused form stays.+ --+ -- The remaining bottleneck is @LA.cmap kernelFromSqDist@ over the+ -- 1M-cell weight matrix — a per-element Haskell function call per+ -- exp(). FFI'd vectorized exp (libmvec / SLEEF) would close the+ -- 3.6× gap to sklearn but is out of scope here.+ let !wMat = gramMatrixMVXY kern h xNew xs -- m × n+ !num = wMat LA.<> ys -- m × q+ !onesN = LA.konst 1 (LA.cols wMat) :: LA.Vector Double+ !denom = wMat LA.#> onesN -- m+ !safe = LA.cmap (\d -> if d == 0 then 1 else 1 / d) denom+ !onesQ = LA.konst 1 (LA.cols num) :: LA.Vector Double+ !safeBc = LA.outer safe onesQ -- m × q+ in safeBc * num
+ src/Hanalyze/Model/LM.hs view
@@ -0,0 +1,314 @@+-- |+-- Module : Hanalyze.Model.LM+-- Description : 最小二乗法による線形回帰の fit・予測・信頼/予測区間+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- [日本語]: 最小二乗法による線形回帰の fit・予測・信頼/予測区間。+--+-- hmatrix の @\\\\@ (LAPACK) を使って @β = (XᵀX)⁻¹ Xᵀ y@ を解く。+-- @t × √(s² xᵢᵀ(XᵀX)⁻¹xᵢ)@ による信頼区間・予測区間、 CLI やレポート+-- ビルダーから使うための @DataFrame@ の便利アダプタを提供する。+--+-- [English]: Fitting, prediction, and confidence\/prediction intervals for+-- ordinary linear regression by least squares.+--+-- Solves @β = (XᵀX)⁻¹ Xᵀ y@ via hmatrix's @\\\\@ (LAPACK). Provides+-- confidence and prediction bands using+-- @t × √(s² xᵢᵀ(XᵀX)⁻¹xᵢ)@ and convenient adapters from a+-- @DataFrame@ for use from the CLI and report builder.+module Hanalyze.Model.LM+ ( LinearModel (..)+ , CIBand (..)+ , SmoothFit (..)+ -- * Matrix-canonical fit+ , fitLM+ , predictLM+ -- * Vector wrapper (1-output convenience)+ , fitLMVec+ , predictLMVec+ -- * Design matrices+ , designMatrix+ , polyDesignMatrix+ , multiPolyDesignMatrix+ , linspace+ -- * DataFrame helpers+ , fitDataFrameLM+ , confidenceBand+ , confidenceBandAt+ , predictionBandAt+ , fitWithCI+ , fitPolyWithSmooth+ ) where++import qualified DataFrame.Internal.DataFrame as DXD+import Hanalyze.DataIO.Convert (getDoubleVec)+import Hanalyze.Model.Core (FitResult (..), Model (..), Band (..),+ coefficientsV, residualsV)++import Data.Text (Text)+import qualified Data.Vector as V+import qualified Numeric.LinearAlgebra as LA+import Statistics.Distribution (quantile)+import Statistics.Distribution.StudentT (studentT)++data LinearModel = LinearModel+ deriving (Show)++instance Model LinearModel where+ fit _ = fitLM+ predict _ = predictLM++-- | [日本語]: Ordinary Least Squares (Matrix canonical、 多出力対応):+-- B = (XᵀX)⁻¹ Xᵀ Y、各列を独立に解く。+-- [English]: Ordinary Least Squares (matrix-canonical form, supports+-- multiple outputs): B = (XᵀX)⁻¹ Xᵀ Y, solving each column independently.+fitLM :: LA.Matrix Double -> LA.Matrix Double -> FitResult+fitLM x y =+ let beta = x LA.<\> y -- p × q+ yHat = x LA.<> beta -- n × q+ resid = y - yHat+ r2 = computeR2Multi y yHat+ in FitResult beta yHat resid r2++predictLM :: LA.Matrix Double -> LA.Matrix Double -> LA.Matrix Double+predictLM beta xNew = xNew LA.<> beta++-- | [日本語]: 単一出力 (Vector y) の便利ラッパ。@asColumn@ で 1 列行列に変換。+-- [English]: Convenience wrapper for a single output (Vector y). Converts+-- to a 1-column matrix with @asColumn@.+fitLMVec :: LA.Matrix Double -> LA.Vector Double -> FitResult+fitLMVec x y = fitLM x (LA.asColumn y)++-- | [日本語]: 1 出力での予測 (β は Vector)。+-- [English]: Prediction for a single output (β is a Vector).+predictLMVec :: LA.Vector Double -> LA.Matrix Double -> LA.Vector Double+predictLMVec beta xNew = xNew LA.#> beta++-- | Build intercept + single predictor design matrix [1, x].+designMatrix :: V.Vector Double -> LA.Matrix Double+designMatrix xs = LA.fromColumns+ [ LA.konst 1.0 n+ , LA.fromList (V.toList xs)+ ]+ where n = V.length xs++-- | Convenience: fit a simple LM directly from a DataFrame.+fitDataFrameLM :: DXD.DataFrame -> Text -> Text -> Maybe FitResult+fitDataFrameLM df xCol yCol = do+ xVec <- getDoubleVec xCol df+ yVec <- getDoubleVec yCol df+ let dm = designMatrix xVec+ y = LA.fromList (V.toList yVec)+ return (fitLMVec dm y)++data CIBand = CIBand+ { lowerBound :: [Double]+ , upperBound :: [Double]+ , ciLevel :: Double+ } deriving (Show)++-- | [日本語]: Pointwise confidence band for the mean response (1 出力前提)。+-- Formula: ŷᵢ ± t_{α/2, n−p} × sqrt(s² × xᵢᵀ (XᵀX)⁻¹ xᵢ)+--+-- 訓練設計行列上で評価する版 (= 各点の中心は fitted)。 grid 評価が要るときは+-- @confidenceBandAt@ を使う。+-- [English]: Pointwise confidence band for the mean response (assumes a+-- single output). Formula: ŷᵢ ± t_{α/2, n−p} × sqrt(s² × xᵢᵀ (XᵀX)⁻¹ xᵢ).+--+-- The variant evaluated on the training design matrix (= each point's+-- center is the fitted value). Use @confidenceBandAt@ when grid+-- evaluation is needed.+confidenceBand :: LA.Matrix Double -> FitResult -> Double -> CIBand+confidenceBand x res level = confidenceBandAt x res level x++-- | [日本語]: 訓練設計行列 @xTrain@ で推定した分散核 (s², (XᵀX)⁻¹, t 値) を、 別の+-- 評価点設計行列 @xEval@ の各行で band 化する。 中心は @xEval·β@、 半幅は+-- @t × √(s² × x₀ᵀ (XᵀX)⁻¹ x₀)@。 自由度・s² は訓練データで決まる。+--+-- ★grid 評価の核: 訓練点ではなく等間隔 grid の設計行列を @xEval@ に渡すと、+-- 回帰曲線・CI 帯が滑らかになる (= 疎・不均一データのガタつき解消)。 訓練点を+-- そのまま渡せば @confidenceBand@ と一致する (LM では @xTrain·β = fitted@)。+-- [English]: Takes the variance core (s², (XᵀX)⁻¹, t value) estimated on+-- the training design matrix @xTrain@ and turns each row of a separate+-- evaluation-point design matrix @xEval@ into a band. The center is+-- @xEval·β@, and the half-width is @t × √(s² × x₀ᵀ (XᵀX)⁻¹ x₀)@. The+-- degrees of freedom and s² are determined by the training data.+--+-- ★The core of grid evaluation: passing an evenly-spaced grid's design+-- matrix (instead of the training points) as @xEval@ smooths out the+-- regression curve and CI band (= removes jaggedness from sparse\/uneven+-- data). Passing the training points directly reproduces+-- @confidenceBand@ (for LM, @xTrain·β = fitted@).+confidenceBandAt+ :: LA.Matrix Double -- ^ [日本語]: 訓練設計行列 X (分散核の推定元)。 [English]: The training design matrix X (source of the variance core).+ -> FitResult -- ^ [日本語]: fit 結果 (β / 残差)。 [English]: The fit result (β \/ residuals).+ -> Double -- ^ [日本語]: 信頼水準 (例 0.95)。 [English]: The confidence level (e.g. 0.95).+ -> LA.Matrix Double -- ^ [日本語]: 評価点設計行列 X₀ (band を評価する行)。 [English]: The evaluation-point design matrix X₀ (rows at which the band is evaluated).+ -> CIBand+confidenceBandAt xTrain res level xEval =+ let df = fromIntegral (LA.rows xTrain - LA.cols xTrain)+ beta = coefficientsV res+ rs = LA.toRows xEval+ yHats = [ xi `LA.dot` beta | xi <- rs ]+ -- df<=0 (飽和・過剰指定) は s²=0/0・studentT が例外 → CI 定義不能。+ -- 幅ゼロ帯 (lo=hi=ŷ) を返し、 帯は線に潰す (呼び元は線のみ描く)。+ in if df <= 0+ then CIBand yHats yHats level+ else+ let resV = residualsV res+ s2 = (resV `LA.dot` resV) / df+ xtxi = LA.inv (LA.tr xTrain LA.<> xTrain)+ tVal = quantile (studentT df) ((1.0 + level) / 2.0)+ se xi = tVal * sqrt (s2 * (xi `LA.dot` (xtxi LA.#> xi)))+ los = zipWith (\yh xi -> yh - se xi) yHats rs+ his = zipWith (\yh xi -> yh + se xi) yHats rs+ in CIBand los his level++-- | [日本語]: 予測区間 (prediction interval) 版の @confidenceBandAt@。 半幅に+-- __観測分散__ @σ̂²@ を 1 つ加える: @t × √(s² × (1 + x₀ᵀ (XᵀX)⁻¹ x₀))@+-- (CI には @1 +@ が無い)。 = 新規観測 1 点が入る区間 (平均の信頼区間より広い)。+-- statsmodels の @get_prediction().summary_frame()['obs_ci_lower/upper']@+-- と一致する。+-- [English]: The prediction-interval version of @confidenceBandAt@. Adds+-- one unit of __observation variance__ @σ̂²@ to the half-width:+-- @t × √(s² × (1 + x₀ᵀ (XᵀX)⁻¹ x₀))@ (the CI has no @1 +@ term). This is+-- the interval that contains a single new observation (wider than the+-- confidence interval for the mean). Matches statsmodels'+-- @get_prediction().summary_frame()['obs_ci_lower/upper']@.+-- (hanalyze-portable)+predictionBandAt+ :: LA.Matrix Double -- ^ [日本語]: 訓練設計行列 X (分散核の推定元)。 [English]: The training design matrix X (source of the variance core).+ -> FitResult -- ^ [日本語]: fit 結果 (β / 残差)。 [English]: The fit result (β \/ residuals).+ -> Double -- ^ [日本語]: 信頼水準 (例 0.95)。 [English]: The confidence level (e.g. 0.95).+ -> LA.Matrix Double -- ^ [日本語]: 評価点設計行列 X₀ (band を評価する行)。 [English]: The evaluation-point design matrix X₀ (rows at which the band is evaluated).+ -> CIBand+predictionBandAt xTrain res level xEval =+ let df = fromIntegral (LA.rows xTrain - LA.cols xTrain)+ beta = coefficientsV res+ rs = LA.toRows xEval+ yHats = [ xi `LA.dot` beta | xi <- rs ]+ -- df<=0 は CI/PI 定義不能 → 幅ゼロ帯 (線のみ)。 @confidenceBandAt@ と同方針。+ in if df <= 0+ then CIBand yHats yHats level+ else+ let resV = residualsV res+ s2 = (resV `LA.dot` resV) / df+ xtxi = LA.inv (LA.tr xTrain LA.<> xTrain)+ tVal = quantile (studentT df) ((1.0 + level) / 2.0)+ se xi = tVal * sqrt (s2 * (1 + xi `LA.dot` (xtxi LA.#> xi))) -- ★CI との差は (1 +)+ los = zipWith (\yh xi -> yh - se xi) yHats rs+ his = zipWith (\yh xi -> yh + se xi) yHats rs+ in CIBand los his level++-- | Fit LM and compute confidence band in one step.+fitWithCI :: Double -> DXD.DataFrame -> Text -> Text -> Maybe (FitResult, CIBand)+fitWithCI level df xCol yCol = do+ xVec <- getDoubleVec xCol df+ yVec <- getDoubleVec yCol df+ let dm = designMatrix xVec+ y = LA.fromList (V.toList yVec)+ res = fitLMVec dm y+ return (res, confidenceBand dm res level)++-- | Polynomial design matrix [1, x, x², …, xᵈ].+polyDesignMatrix :: Int -> V.Vector Double -> LA.Matrix Double+polyDesignMatrix degree xs = LA.fromColumns+ [ LA.fromList [ x ^ k | x <- V.toList xs ]+ | k <- [0 .. degree]+ ]++-- | Multi-column polynomial design matrix.+-- Builds [1, x1, x1², …, x1^d1, x2, …, x2^d2, …] from a list of (column, degree) pairs.+multiPolyDesignMatrix :: [(V.Vector Double, Int)] -> LA.Matrix Double+multiPolyDesignMatrix [] = error "multiPolyDesignMatrix: empty predictor list"+multiPolyDesignMatrix colDegs@((firstXs, _) : _) =+ LA.fromColumns (intercept : concatMap polyExpand colDegs)+ where+ n = V.length firstXs+ intercept = LA.konst 1.0 n+ polyExpand (xs, deg) =+ [ LA.fromList [ x ^ k | x <- V.toList xs ] | k <- [1 .. deg] ]++-- | Grid of evenly spaced values from lo to hi.+linspace :: Double -> Double -> Int -> [Double]+linspace lo hi n+ | n <= 1 = [lo]+ | otherwise = [ lo + fromIntegral i * (hi - lo) / fromIntegral (n - 1)+ | i <- [0 .. n - 1] ]++-- | Pre-computed smooth curve data for plotting (evaluated on a fine grid).+data SmoothFit = SmoothFit+ { sfX :: [Double]+ , sfFit :: [Double]+ , sfLower :: [Double]+ , sfUpper :: [Double]+ , sfHasBand :: Bool+ } deriving (Show)++-- | Fit polynomial LM of given degree and compute a smooth curve with optional band+-- on a fine grid of nGrid points for clean visualisation.+fitPolyWithSmooth+ :: Band+ -> Int+ -> DXD.DataFrame+ -> Text+ -> Text+ -> Maybe (FitResult, SmoothFit)+fitPolyWithSmooth band nGrid df xCol yCol = do+ xVec <- getDoubleVec xCol df+ yVec <- getDoubleVec yCol df+ let degree = 1+ dm = polyDesignMatrix degree xVec+ y = LA.fromList (V.toList yVec)+ res = fitLMVec dm y+ beta = coefficientsV res++ xLa = LA.fromList (V.toList xVec)+ xGrid = V.fromList (linspace (LA.minElement xLa) (LA.maxElement xLa) nGrid)+ dmG = polyDesignMatrix degree xGrid+ yGrid = LA.toList (dmG LA.#> beta)++ dfStat = fromIntegral (LA.rows dm - LA.cols dm) :: Double+ resV = residualsV res+ s2 = (resV `LA.dot` resV) / dfStat+ xtxi = LA.inv (LA.tr dm LA.<> dm)+ gRows = LA.toRows dmG++ computeBand level isPI+ -- df<=0 (飽和) は s²=0/0・studentT が例外 → 帯を線に潰す (lo=hi=yGrid)。+ | dfStat <= 0 = (yGrid, yGrid)+ | otherwise =+ let tVal = quantile (studentT dfStat) ((1.0 + level) / 2.0)+ extra = if isPI then 1.0 else 0.0+ halfW xi = tVal * sqrt (s2 * (extra + xi `LA.dot` (xtxi LA.#> xi)))+ los = zipWith (\yh xi -> yh - halfW xi) yGrid gRows+ his = zipWith (\yh xi -> yh + halfW xi) yGrid gRows+ in (los, his)++ case band of+ NoBand ->+ return (res, SmoothFit (V.toList xGrid) yGrid yGrid yGrid False)+ CI level ->+ let (los, his) = computeBand level False+ in return (res, SmoothFit (V.toList xGrid) yGrid los his True)+ PI level ->+ let (los, his) = computeBand level True+ in return (res, SmoothFit (V.toList xGrid) yGrid los his True)++-- | [日本語]: 各列ごとに R² を計算 (多出力対応)。+-- [English]: Computes R² for each column (supports multiple outputs).+computeR2Multi :: LA.Matrix Double -> LA.Matrix Double -> LA.Vector Double+computeR2Multi y yHat =+ let q = LA.cols y+ in LA.fromList+ [ let yj = LA.flatten (y LA.¿ [j])+ yhj = LA.flatten (yHat LA.¿ [j])+ resid = yj - yhj+ yMean = LA.sumElements yj / fromIntegral (LA.size yj)+ dev = LA.cmap (subtract yMean) yj+ ssRes = resid `LA.dot` resid+ ssTot = dev `LA.dot` dev+ in if ssTot == 0 then 0+ else 1.0 - ssRes / ssTot+ | j <- [0 .. q - 1] ]
+ src/Hanalyze/Model/LM/Diagnostics.hs view
@@ -0,0 +1,278 @@+-- |+-- Module : Hanalyze.Model.LM.Diagnostics+-- Description : 線形回帰の推論統計量 (標準誤差・t/p 値・F 統計量・AIC/BIC・レバレッジ・Cook's distance)+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- Inference and residual diagnostics for ordinary linear regression.+--+-- Provides standard errors, t / p-values, F-statistic, information+-- criteria (AIC / BIC), leverage / hat-diagonal, standardised+-- residuals, and Cook's distance. All multi-output operators+-- (@q@ output columns) follow the @Matrix p × q@ canonical convention,+-- with @Vector p@ wrappers for the @q = 1@ case.+module Hanalyze.Model.LM.Diagnostics+ ( -- * t-quantile+ ciTValue+ -- * Per-coefficient inference (Multi-output canonical)+ , CoefStats (..)+ , lmSigmaSqMulti+ , lmCovarianceMulti+ , lmStdErrorsMulti+ , lmCoefStatsMulti+ -- * 1-output convenience wrappers+ , lmStdErrors+ , lmCoefStats+ -- * Whole-model F-statistic+ , FStat (..)+ , lmFStatistic+ -- * Information criteria+ , ICs (..)+ , lmInformationCriteria+ , lmInformationCriteriaMulti+ -- * Residual diagnostics+ , hatDiagonal+ , standardizedResiduals+ , cooksDistance+ -- * Predictor utilities+ , predictorStdDevs+ ) where++import Hanalyze.Model.Core (FitResult (..))+import qualified Numeric.LinearAlgebra as LA+import qualified Statistics.Distribution as SD+import qualified Statistics.Distribution.FDistribution as FD+import Statistics.Distribution.StudentT (studentT)++-- ---------------------------------------------------------------------------+-- t-quantile+-- ---------------------------------------------------------------------------++-- | Two-sided Student-t quantile @t_{α/2, df}@ at confidence+-- @level@ (e.g. @0.95@) and degrees of freedom @df@.+ciTValue :: Double -> Int -> Double+ciTValue level df =+ SD.quantile (studentT (fromIntegral df)) ((1.0 + level) / 2.0)++-- ---------------------------------------------------------------------------+-- Helpers shared across diagnostics+-- ---------------------------------------------------------------------------++-- | Per-output residual variance @σ²_k = RSS_k / (n − p)@. Returns a+-- length-@q@ vector.+lmSigmaSqMulti :: FitResult -> LA.Vector Double+lmSigmaSqMulti res =+ let r = residuals res+ n = LA.rows r+ p = LA.rows (coefficients res)+ df = fromIntegral (n - p) :: Double+ cols = LA.toColumns r+ ssRes c = c `LA.dot` c+ in LA.fromList [ ssRes c / df | c <- cols ]++-- | Per-output coefficient covariance matrices. Returns a list of+-- @q@ symmetric @p × p@ matrices, one per output column:+-- @Cov_k = σ²_k × (XᵀX)⁻¹@.+lmCovarianceMulti :: LA.Matrix Double -> FitResult -> [LA.Matrix Double]+lmCovarianceMulti x res =+ let xtxi = LA.inv (LA.tr x LA.<> x)+ sig2s = LA.toList (lmSigmaSqMulti res)+ in [ LA.scale s2 xtxi | s2 <- sig2s ]++-- ---------------------------------------------------------------------------+-- Standard errors+-- ---------------------------------------------------------------------------++-- | Per-coefficient, per-output standard errors as a @p × q@ matrix:+-- @SE_{jk} = √(diag(Cov_k)_j)@.+lmStdErrorsMulti :: LA.Matrix Double -> FitResult -> LA.Matrix Double+lmStdErrorsMulti x res =+ let covs = lmCovarianceMulti x res+ cols = [ LA.cmap sqrt (LA.takeDiag c) | c <- covs ]+ in LA.fromColumns cols++-- | 1-output convenience: standard errors as a length-@p@ vector.+lmStdErrors :: LA.Matrix Double -> FitResult -> LA.Vector Double+lmStdErrors x res = LA.flatten (lmStdErrorsMulti x res)++-- ---------------------------------------------------------------------------+-- Coefficient stats (SE / t / two-sided p)+-- ---------------------------------------------------------------------------++-- | Per-coefficient inference triple: standard error, Wald @t@ value,+-- and two-sided @p@ value @2 × (1 − F_t(|t|; df))@.+data CoefStats = CoefStats+ { csSE :: !Double+ , csTValue :: !Double+ , csPValue :: !Double+ } deriving (Show, Eq)++-- | Per-output 'CoefStats' for every coefficient. Returns a list of+-- @q@ lists, each of length @p@.+lmCoefStatsMulti :: LA.Matrix Double -> FitResult -> [[CoefStats]]+lmCoefStatsMulti x res =+ let n = LA.rows x+ p = LA.cols x+ df = fromIntegral (n - p) :: Double+ tDist = studentT df+ betaCs = LA.toColumns (coefficients res)+ seCs = LA.toColumns (lmStdErrorsMulti x res)+ pair beta se =+ zipWith+ (\b s ->+ let t = if s == 0 then 0 else b / s+ pv = 2.0 * (1.0 - SD.cumulative tDist (abs t))+ in CoefStats s t pv)+ (LA.toList beta) (LA.toList se)+ in zipWith pair betaCs seCs++-- | 1-output convenience: 'CoefStats' for every coefficient.+lmCoefStats :: LA.Matrix Double -> FitResult -> [CoefStats]+lmCoefStats x res = head (lmCoefStatsMulti x res)++-- ---------------------------------------------------------------------------+-- F-statistic (whole-model)+-- ---------------------------------------------------------------------------++-- | Whole-model F-statistic and its right-tail @p@ value:+-- @F = ((TSS − RSS)/(p − 1)) / (RSS/(n − p))@,+-- @F ~ F(p − 1, n − p)@.+data FStat = FStat+ { fsValue :: !Double+ , fsPValue :: !Double+ , fsDf1 :: !Int+ , fsDf2 :: !Int+ } deriving (Show, Eq)++-- | Whole-model F-statistic per output column. The first design-matrix+-- column is assumed to be the intercept (so the effective number of+-- predictors is @p − 1@). For @p ≤ 1@ or @n ≤ p@ returns @F = 0@,+-- @p = 1@.+lmFStatistic :: LA.Matrix Double -> FitResult -> [FStat]+lmFStatistic x res =+ let n = LA.rows x+ p = LA.cols x+ df1 = p - 1+ df2 = n - p+ yMat = fitted res + residuals res+ yCs = LA.toColumns yMat+ rCs = LA.toColumns (residuals res)+ go yj rj =+ if df1 <= 0 || df2 <= 0+ then FStat 0 1 (max df1 0) (max df2 0)+ else+ let yMean = LA.sumElements yj / fromIntegral (LA.size yj)+ dev = LA.cmap (subtract yMean) yj+ tss = dev `LA.dot` dev+ rss = rj `LA.dot` rj+ ess = tss - rss+ fVal = (ess / fromIntegral df1) / (rss / fromIntegral df2)+ pVal = if rss == 0+ then 0+ else SD.complCumulative+ (FD.fDistribution df1 df2) fVal+ in FStat fVal pVal df1 df2+ in zipWith go yCs rCs++-- ---------------------------------------------------------------------------+-- Information criteria (Gaussian LM)+-- ---------------------------------------------------------------------------++-- | Gaussian log-likelihood, AIC, and BIC under the standard+-- @ε ~ N(0, σ²)@ assumption.+data ICs = ICs+ { icLogLik :: !Double+ , icAIC :: !Double+ , icBIC :: !Double+ } deriving (Show, Eq)++-- | Per-output information criteria.+--+-- @+-- logLik = −n/2 × (log(2π) + log(RSS/n) + 1)+-- AIC = 2k − 2 × logLik (k = p + 1, σ² counted)+-- BIC = k × log(n) − 2 × logLik+-- @+lmInformationCriteriaMulti :: FitResult -> [ICs]+lmInformationCriteriaMulti res =+ let r = residuals res+ n = LA.rows r+ p = LA.rows (coefficients res)+ k = fromIntegral (p + 1) :: Double+ nD = fromIntegral n :: Double+ cols = LA.toColumns r+ go c =+ let rss = c `LA.dot` c+ logLik = -nD / 2.0 *+ (log (2.0 * pi) + log (rss / nD) + 1.0)+ aic = 2.0 * k - 2.0 * logLik+ bic = k * log nD - 2.0 * logLik+ in ICs logLik aic bic+ in map go cols++-- | 1-output convenience.+lmInformationCriteria :: FitResult -> ICs+lmInformationCriteria = head . lmInformationCriteriaMulti++-- ---------------------------------------------------------------------------+-- Residual diagnostics+-- ---------------------------------------------------------------------------++-- | Hat-matrix diagonal @h_ii = xᵢᵀ (XᵀX)⁻¹ xᵢ@. Returns a length-@n@+-- vector independent of the response.+hatDiagonal :: LA.Matrix Double -> LA.Vector Double+hatDiagonal x =+ let xtxi = LA.inv (LA.tr x LA.<> x)+ rows = LA.toRows x+ in LA.fromList [ xi `LA.dot` (xtxi LA.#> xi) | xi <- rows ]++-- | Internally studentised residual @r̃_i = r_i / (σ × √(1 − h_ii))@.+-- 1-output only (multi-output leverage is the same; the standardisation+-- divides by per-column @σ@). Returns a length-@n@ vector.+standardizedResiduals :: LA.Matrix Double -> FitResult -> LA.Vector Double+standardizedResiduals x res =+ let n = LA.rows x+ p = LA.cols x+ rj = LA.flatten (residuals res) -- assumes q = 1+ rss = rj `LA.dot` rj+ sigma = sqrt (rss / fromIntegral (n - p))+ h = hatDiagonal x+ one h_ = max 0.0 (1.0 - h_)+ in LA.fromList+ [ if sigma == 0 || one hi == 0+ then 0+ else ri / (sigma * sqrt (one hi))+ | (ri, hi) <- zip (LA.toList rj) (LA.toList h) ]++-- | Cook's distance @D_i = (r̃_i² / p) × (h_ii / (1 − h_ii))@.+-- 1-output only. Returns a length-@n@ vector.+cooksDistance :: LA.Matrix Double -> FitResult -> LA.Vector Double+cooksDistance x res =+ let p = fromIntegral (LA.cols x) :: Double+ h = hatDiagonal x+ rTil = standardizedResiduals x res+ in LA.fromList+ [ let denom = max 0.0 (1.0 - hi)+ in if denom == 0+ then 0+ else (rTi * rTi / p) * (hi / denom)+ | (rTi, hi) <- zip (LA.toList rTil) (LA.toList h) ]++-- ---------------------------------------------------------------------------+-- Predictor utilities+-- ---------------------------------------------------------------------------++-- | Per-column sample standard deviation of the design matrix+-- (length @p@). Useful for standardised contribution+-- @|β_j × sd(x_j)| / Σ|β_k × sd(x_k)|@. The intercept column is+-- typically constant, so its entry is @0@.+predictorStdDevs :: LA.Matrix Double -> LA.Vector Double+predictorStdDevs x =+ let n = fromIntegral (LA.rows x) :: Double+ cs = LA.toColumns x+ sd c =+ let mu = LA.sumElements c / n+ dev = LA.cmap (subtract mu) c+ v = (dev `LA.dot` dev) / max 1.0 (n - 1.0)+ in sqrt v+ in LA.fromList (map sd cs)
+ src/Hanalyze/Model/LatentClassAnalysis.hs view
@@ -0,0 +1,212 @@+-- |+-- Module : Hanalyze.Model.LatentClassAnalysis+-- Description : EM アルゴリズムによる潜在クラス分析 (LCA、R poLCA 相当)+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- [日本語]: Latent Class Analysis (LCA) via EM algorithm。+--+-- カテゴリ変数の潜在クラスクラスタリング。 @K@ 個の潜在クラスを仮定し、+-- 各クラスでの各 categorical 特徴の条件付き分布 @P(X_j | class)@ を推定する。+-- R @poLCA@ 相当。+--+-- ## モデル+--+-- @+-- P(X_i) = Σ_k π_k · Π_j ρ_{k, j, X_{i,j}}+-- @+--+-- ここで @π_k@ はクラス混合重み、 @ρ_{k,j,l}@ はクラス @k@ で特徴 @j@ が+-- 水準 @l@ を取る確率。+--+-- ## EM+--+-- - __E-step__: posterior @γ_{i,k} = π_k Π_j ρ_{k,j,X_{i,j}} / Σ_{k'} (...)@+-- - __M-step__: @π_k ← (1/n) Σ_i γ_{i,k}@、+-- @ρ_{k,j,l} ← Σ_i γ_{i,k} [X_{i,j} = l] / Σ_i γ_{i,k}@+--+-- Reference: Linzer-Lewis (2011) "poLCA: An R package for polytomous+-- variable latent class analysis". J Stat Softw 42(10).+--+-- [English]: Latent Class Analysis (LCA) via the EM algorithm.+--+-- Latent-class clustering of categorical variables. Assumes @K@+-- latent classes, and estimates the conditional distribution+-- @P(X_j | class)@ of each categorical feature within each class.+-- Equivalent to R's @poLCA@.+--+-- ## Model+--+-- @+-- P(X_i) = Σ_k π_k · Π_j ρ_{k, j, X_{i,j}}+-- @+--+-- where @π_k@ is the class mixing weight and @ρ_{k,j,l}@ is the+-- probability that feature @j@ takes level @l@ in class @k@.+--+-- ## EM+--+-- - __E-step__: posterior @γ_{i,k} = π_k Π_j ρ_{k,j,X_{i,j}} / Σ_{k'} (...)@+-- - __M-step__: @π_k ← (1/n) Σ_i γ_{i,k}@,+-- @ρ_{k,j,l} ← Σ_i γ_{i,k} [X_{i,j} = l] / Σ_i γ_{i,k}@+--+-- Reference: Linzer-Lewis (2011) "poLCA: An R package for polytomous+-- variable latent class analysis". J Stat Softw 42(10).+module Hanalyze.Model.LatentClassAnalysis+ ( LCAFit (..)+ , fitLCA+ ) where++import qualified Numeric.LinearAlgebra as LA+import qualified System.Random.MWC as MWC+import Control.Monad (replicateM)++-- ---------------------------------------------------------------------------+-- 型+-- ---------------------------------------------------------------------------++data LCAFit = LCAFit+ { lcaPi :: !(LA.Vector Double) -- ^ class mixing weights (length K)+ , lcaRho :: ![LA.Matrix Double] -- ^ per feature: K × L (length J)+ , lcaResponsibilities :: !(LA.Matrix Double) -- ^ posterior γ (n × K)+ , lcaIterations :: !Int+ , lcaConverged :: !Bool+ , lcaLogLik :: !Double+ } deriving (Show)++-- ---------------------------------------------------------------------------+-- fitLCA+-- ---------------------------------------------------------------------------++-- | [日本語]: @K@ クラス、 @L@ 水準の LCA を EM で fit。 入力 @X@ は @n@ 行 @J@ 列の+-- 0-indexed カテゴリ値 (`[[Int]]`、 全要素 ∈ @[0, L-1]@)。+--+-- 初期化はランダム (Dirichlet(1) ≈ uniform-on-simplex の近似で MWC を使う)。+-- 同じ seed で再現性あり。+--+-- [English]: Fits an LCA with @K@ classes and @L@ levels via EM. The+-- input @X@ is 0-indexed category values with @n@ rows and @J@ columns+-- (`[[Int]]`, all elements ∈ @[0, L-1]@).+--+-- Initialization is random (using MWC as an approximation of+-- Dirichlet(1) ≈ uniform-on-simplex). Reproducible for the same seed.+fitLCA+ :: Int -- ^ K (classes)+ -> Int -- ^ L (levels per feature)+ -> [[Int]] -- ^ X (n × J)+ -> Int -- ^ max EM iterations+ -> Double -- ^ tolerance on log-likelihood diff+ -> MWC.GenIO+ -> IO LCAFit+fitLCA k l xRaw maxIter tol gen = do+ let n = length xRaw+ j = if n > 0 then length (head xRaw) else 0+ -- 初期化+ pi0 <- randomSimplex k gen+ rho0 <- replicateM j (randomRowStochastic k l gen)+ let xMat = LA.fromLists [map fromIntegral row | row <- xRaw]+ go !it !pVec !rhoList !prevLL = do+ let (gamma, ll) = eStep xMat pVec rhoList l+ (pNew, rhoNew) = mStep xMat gamma l+ converged = abs (ll - prevLL) < tol+ if it >= maxIter || converged+ then pure (pVec, rhoList, gamma, it, converged, ll)+ else go (it + 1) pNew rhoNew ll+ -- 初期 ll は -inf で 1 回目は必ず更新される+ (pFinal, rhoFinal, gamFinal, iters, conv, llFinal) <-+ go 0 pi0 rho0 (-1 / 0)+ pure LCAFit+ { lcaPi = pFinal+ , lcaRho = rhoFinal+ , lcaResponsibilities = gamFinal+ , lcaIterations = iters+ , lcaConverged = conv+ , lcaLogLik = llFinal+ }++-- | [日本語]: E-step: per-row posterior @γ_{i,k}@ と log-likelihood。+-- log-space で stable: @log P(X_i | k) = Σ_j log ρ_{k, j, X_{i,j}}@+--+-- [English]: E-step: the per-row posterior @γ_{i,k}@ and the+-- log-likelihood. Stable in log-space:+-- @log P(X_i | k) = Σ_j log ρ_{k, j, X_{i,j}}@.+eStep+ :: LA.Matrix Double -- ^ [日本語]: X (n × J)、 0/1/.../L-1 を Double で。 [English]: X (n × J), with 0/1/.../L-1 as Double.+ -> LA.Vector Double -- ^ π+ -> [LA.Matrix Double] -- ^ [日本語]: ρ (J 個の K × L)。 [English]: ρ (J instances of K × L).+ -> Int -- ^ L+ -> (LA.Matrix Double, Double)+eStep xMat pVec rhoList _ =+ let n = LA.rows xMat+ k = LA.size pVec+ logPi = LA.cmap (\p -> log (max 1e-300 p)) pVec+ logPx_ik i kk =+ sum [ log (max 1e-300+ (LA.atIndex (rhoList !! jj)+ (kk, floor (LA.atIndex xMat (i, jj)))))+ | jj <- [0 .. length rhoList - 1] ]+ logUnnormRow i = LA.fromList+ [ LA.atIndex logPi kk + logPx_ik i kk | kk <- [0 .. k - 1] ]+ rows = [logUnnormRow i | i <- [0 .. n - 1]]+ logSumExpV v =+ let mx = LA.maxElement v+ in mx + log (LA.sumElements (LA.cmap (\x -> exp (x - mx)) v))+ perRowLL = [logSumExpV r | r <- rows]+ gammaRows =+ [ LA.cmap (\x -> exp (x - lse)) r+ | (r, lse) <- zip rows perRowLL ]+ gamma = LA.fromRows gammaRows+ ll = sum perRowLL+ in (gamma, ll)++-- | [日本語]: M-step: γ から π / ρ を更新。+-- [English]: M-step: updates π \/ ρ from γ.+mStep+ :: LA.Matrix Double -- ^ X (n × J)+ -> LA.Matrix Double -- ^ γ (n × K)+ -> Int -- ^ L+ -> (LA.Vector Double, [LA.Matrix Double])+mStep xMat gamma l =+ let n = LA.rows xMat+ j = LA.cols xMat+ k = LA.cols gamma+ ones = LA.konst 1 n :: LA.Vector Double+ gSum = LA.tr gamma LA.#> ones -- length K = Σ_i γ_{i,k}+ pNew = LA.scale (1 / fromIntegral n) gSum+ -- 各特徴 j の ρ (K × L) を再推定+ rhoFor jj =+ let countMat = LA.fromLists+ [ [ sum [ LA.atIndex gamma (i, kk)+ | i <- [0 .. n - 1]+ , floor (LA.atIndex xMat (i, jj)) == ll ]+ | ll <- [0 .. l - 1] ]+ | kk <- [0 .. k - 1] ]+ denom = LA.cmap (\g -> max 1e-300 g) gSum+ in LA.fromColumns+ [ LA.flatten (countMat LA.¿ [c]) / denom+ | c <- [0 .. l - 1] ]+ rhoNew = [rhoFor jj | jj <- [0 .. j - 1]]+ in (pNew, rhoNew)++-- ---------------------------------------------------------------------------+-- 初期化ヘルパ+-- ---------------------------------------------------------------------------++-- | [日本語]: 長さ @k@ の simplex 上の uniform ランダム vector (= Dir(1) 近似)。+-- 単純に @k@ 個の uniform を引いて正規化。+--+-- [English]: A uniform random vector on the length-@k@ simplex (an+-- approximation of Dir(1)). Simply draws @k@ uniforms and normalizes.+randomSimplex :: Int -> MWC.GenIO -> IO (LA.Vector Double)+randomSimplex k gen = do+ rs <- replicateM k (MWC.uniformR (1e-3, 1.0 :: Double) gen)+ let s = sum rs+ pure (LA.fromList (map (/ s) rs))++-- | [日本語]: K × L 行 stochastic matrix のランダム生成。 各行を randomSimplex。+-- [English]: Randomly generates a K × L row-stochastic matrix. Each+-- row via randomSimplex.+randomRowStochastic :: Int -> Int -> MWC.GenIO -> IO (LA.Matrix Double)+randomRowStochastic k l gen = do+ rows <- replicateM k (randomSimplex l gen)+ pure (LA.fromRows rows)
+ src/Hanalyze/Model/LiNGAM/Bootstrap.hs view
@@ -0,0 +1,242 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE BangPatterns #-}+-- |+-- Module : Hanalyze.Model.LiNGAM.Bootstrap+-- Description : BootstrapLiNGAM (エッジ出現頻度・平均係数・符号一致率による DAG confidence 診断)+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- [日本語]: BootstrapLiNGAM: @DirectLiNGAM@ を B 個の bootstrap サンプルに対し fit し、+-- エッジ毎の出現頻度 (confidence) と平均係数を出す。+--+-- ## アルゴリズム+--+-- 1. B 回の bootstrap サンプル (行を with-replacement で n 個抽出) を生成+-- 2. 各サンプルで @fitDirectLiNGAM@ を呼ぶ+-- 3. エッジ (j → i) ごとに:+-- - 出現頻度 = (|B[i, j]| > threshold となった bootstrap の数) / B+-- - 平均係数 = 出現した bootstrap での B[i, j] の平均+-- - 符号一致率 = sign の合致率 (符号の不安定性を診断)+--+-- ## 出力+--+-- 'BootstrapResult' は @edgeProbability@ / @edgeMeanWeight@ / @signConsistency@+-- の 3 つの p × p 行列を保持。 これらを使って 「確からしい因果関係 のみ+-- 採用する DAG」 を構築できる。+--+-- ## リファレンス+--+-- Shimizu (2014) "Bayesian estimation of causal direction in acyclic structural+-- equation models with individual-specific confounder variables and+-- non-Gaussian distributions" (BootstrapLiNGAM の運用紹介)。+-- Python 実装は cdt15/lingam の `lingam/bootstrap.py`。+--+-- [English]: BootstrapLiNGAM: fits @DirectLiNGAM@ to B bootstrap samples,+-- and produces the per-edge occurrence frequency (confidence) and mean+-- coefficient.+--+-- ## Algorithm+--+-- 1. Generate B bootstrap samples (draw n rows with replacement).+-- 2. Call @fitDirectLiNGAM@ on each sample.+-- 3. For each edge (j → i):+-- - occurrence frequency = (the number of bootstraps where+-- |B[i, j]| > threshold) / B.+-- - mean coefficient = the mean of B[i, j] over the bootstraps where it+-- occurred.+-- - sign consistency = the sign-agreement rate (diagnoses sign+-- instability).+--+-- ## Output+--+-- 'BootstrapResult' holds three p × p matrices: @edgeProbability@ \/+-- @edgeMeanWeight@ \/ @signConsistency@. These can be used to build a "DAG+-- that adopts only sufficiently confident causal relations".+--+-- ## Reference+--+-- Shimizu (2014) "Bayesian estimation of causal direction in acyclic+-- structural equation models with individual-specific confounder+-- variables and non-Gaussian distributions" (introduces the+-- BootstrapLiNGAM procedure). The Python implementation is+-- cdt15/lingam's `lingam/bootstrap.py`.+module Hanalyze.Model.LiNGAM.Bootstrap+ ( BootstrapConfig (..)+ , BootstrapResult (..)+ , defaultBootstrapConfig+ , fitBootstrapLiNGAM+ , fitBootstrapLiNGAMPure+ , confidenceDAG+ ) where++import qualified Numeric.LinearAlgebra as LA+import qualified System.Random.MWC as MWC+import Control.Monad (replicateM)+import Control.Monad.ST (runST)+import qualified Data.Vector as V++import qualified Hanalyze.Model.LiNGAM.Direct as DL+import qualified Hanalyze.Model.DAG as DAG++-- ===========================================================================+-- 設定+-- ===========================================================================++data BootstrapConfig = BootstrapConfig+ { bcNumBootstraps :: !Int+ -- ^ [日本語]: B (resample 回数)、 default 100。+ -- [English]: B (the number of resamples), default 100.+ , bcDirectCfg :: !DL.DirectLiNGAMConfig+ -- ^ [日本語]: 各 bootstrap で使う DirectLiNGAM 設定。+ -- [English]: The DirectLiNGAM configuration used for each bootstrap.+ , bcEdgeThreshold :: !Double+ -- ^ [日本語]: |B[i, j]| > thr のとき「エッジあり」 と数える、 default 0.05。+ -- [English]: Counted as "edge present" when |B[i, j]| > thr, default+ -- 0.05.+ , bcSeed :: !(Maybe Int)+ } deriving (Show)++defaultBootstrapConfig :: BootstrapConfig+defaultBootstrapConfig = BootstrapConfig+ { bcNumBootstraps = 100+ , bcDirectCfg = DL.defaultDirectLiNGAMConfig+ , bcEdgeThreshold = 0.05+ , bcSeed = Just 42+ }++-- ===========================================================================+-- 結果+-- ===========================================================================++data BootstrapResult = BootstrapResult+ { brEdgeProbability :: !(LA.Matrix Double)+ -- ^ [日本語]: p × p、 (i, j) = エッジ j → i の出現頻度 (0..1)。+ -- [English]: p × p; (i, j) = the occurrence frequency of edge j → i+ -- (0..1).+ , brEdgeMeanWeight :: !(LA.Matrix Double)+ -- ^ [日本語]: p × p、 (i, j) = エッジが出現した bootstrap における B[i, j] の平均。+ -- [English]: p × p; (i, j) = the mean of B[i, j] over the bootstraps+ -- where the edge occurred.+ , brSignConsistency :: !(LA.Matrix Double)+ -- ^ [日本語]: p × p、 (i, j) = エッジが出現した bootstrap での符号合致率+ -- (1.0 = 全部同符号、 0.5 = 半々)。+ -- [English]: p × p; (i, j) = the sign-agreement rate over the+ -- bootstraps where the edge occurred (1.0 = all the same sign, 0.5 =+ -- evenly split).+ , brNumBootstraps :: !Int+ } deriving (Show)++-- ===========================================================================+-- 主実装+-- ===========================================================================++fitBootstrapLiNGAM :: BootstrapConfig -> LA.Matrix Double -> IO BootstrapResult+fitBootstrapLiNGAM cfg xs = do+ let !n = LA.rows xs+ !p = LA.cols xs+ !b = bcNumBootstraps cfg+ !thr = bcEdgeThreshold cfg+ gen <- case bcSeed cfg of+ Just s -> MWC.initialize (V.fromList [fromIntegral s])+ Nothing -> MWC.createSystemRandom+ -- 各 bootstrap の B 行列を集める+ bMats <- replicateM b $ do+ idxs <- V.replicateM n (MWC.uniformR (0, n - 1) gen)+ let !resample = xs LA.? V.toList idxs+ !fit = DL.fitDirectLiNGAM (bcDirectCfg cfg) resample+ pure (DL.dlB fit)+ let !probMat = computeEdgeProbability thr p bMats+ !meanMat = computeEdgeMeanWeight thr p bMats+ !signMat = computeSignConsistency thr p bMats+ pure BootstrapResult+ { brEdgeProbability = probMat+ , brEdgeMeanWeight = meanMat+ , brSignConsistency = signMat+ , brNumBootstraps = b+ }++-- | [日本語]: 'fitBootstrapLiNGAM' の __seed 純粋版__ (@df |->@ 用)。 @bcSeed@ (既定 42・+-- 'Nothing' は 42 fallback) で 'runST'+MWC。 同 seed で IO 版とビット一致 (乱数列は monad 非依存)。+-- [English]: The __seed-pure version__ of 'fitBootstrapLiNGAM' (for+-- @df |->@). Runs 'runST'+MWC with @bcSeed@ (default 42; 'Nothing' falls+-- back to 42). Bit-identical to the IO version for the same seed (the+-- random sequence is monad-independent).+fitBootstrapLiNGAMPure :: BootstrapConfig -> LA.Matrix Double -> BootstrapResult+fitBootstrapLiNGAMPure cfg xs = runST $ do+ let !n = LA.rows xs+ !p = LA.cols xs+ !b = bcNumBootstraps cfg+ !thr = bcEdgeThreshold cfg+ gen <- MWC.initialize (V.fromList [fromIntegral (maybe 42 id (bcSeed cfg))])+ bMats <- replicateM b $ do+ idxs <- V.replicateM n (MWC.uniformR (0, n - 1) gen)+ let !resample = xs LA.? V.toList idxs+ pure (DL.dlB (DL.fitDirectLiNGAM (bcDirectCfg cfg) resample))+ pure BootstrapResult+ { brEdgeProbability = computeEdgeProbability thr p bMats+ , brEdgeMeanWeight = computeEdgeMeanWeight thr p bMats+ , brSignConsistency = computeSignConsistency thr p bMats+ , brNumBootstraps = b+ }++-- | [日本語]: 「出現頻度 ≥ probThreshold かつ符号合致率 ≥ signThreshold」 のエッジだけ+-- 採用した DAG を構築。 重みは 'brEdgeMeanWeight' を使う。+-- [English]: Builds a DAG that adopts only edges with "occurrence+-- frequency ≥ probThreshold and sign consistency ≥ signThreshold". Uses+-- 'brEdgeMeanWeight' for the weights.+confidenceDAG+ :: Double -- 出現頻度閾値 (例 0.7)+ -> Double -- 符号合致率閾値 (例 0.8)+ -> BootstrapResult+ -> DAG.DAG+confidenceDAG probThr signThr res =+ let !p = LA.rows (brEdgeProbability res)+ f i j+ | i == j = 0+ | LA.atIndex (brEdgeProbability res) (i, j) < probThr = 0+ | LA.atIndex (brSignConsistency res) (i, j) < signThr = 0+ | otherwise = LA.atIndex (brEdgeMeanWeight res) (i, j)+ w = LA.build (p, p) (\i j -> f (round i) (round j) :: Double)+ in DAG.mkDAG w++-- ===========================================================================+-- 内部: 集計+-- ===========================================================================++computeEdgeProbability :: Double -> Int -> [LA.Matrix Double] -> LA.Matrix Double+computeEdgeProbability thr p bMats =+ let !n = fromIntegral (length bMats) :: Double+ f i j+ | i == j = 0+ | otherwise =+ let !cnt = length [ () | b <- bMats+ , abs (LA.atIndex b (i, j)) > thr ]+ in fromIntegral cnt / n+ in LA.build (p, p) (\i j -> f (round i) (round j) :: Double)++computeEdgeMeanWeight :: Double -> Int -> [LA.Matrix Double] -> LA.Matrix Double+computeEdgeMeanWeight thr p bMats =+ let f i j+ | i == j = 0+ | otherwise =+ let vs = [ LA.atIndex b (i, j)+ | b <- bMats+ , abs (LA.atIndex b (i, j)) > thr ]+ in if null vs then 0 else sum vs / fromIntegral (length vs)+ in LA.build (p, p) (\i j -> f (round i) (round j) :: Double)++computeSignConsistency :: Double -> Int -> [LA.Matrix Double] -> LA.Matrix Double+computeSignConsistency thr p bMats =+ let f i j+ | i == j = 0+ | otherwise =+ let vs = [ LA.atIndex b (i, j)+ | b <- bMats+ , abs (LA.atIndex b (i, j)) > thr ]+ in if null vs then 0+ else let !nPos = length (filter (> 0) vs)+ !nNeg = length (filter (< 0) vs)+ !tot = nPos + nNeg+ in if tot == 0 then 0+ else fromIntegral (max nPos nNeg) / fromIntegral tot+ in LA.build (p, p) (\i j -> f (round i) (round j) :: Double)
+ src/Hanalyze/Model/LiNGAM/Direct.hs view
@@ -0,0 +1,396 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE BangPatterns #-}+-- |+-- Module : Hanalyze.Model.LiNGAM.Direct+-- Description : DirectLiNGAM (Shimizu 2011) による線形非ガウシアン因果探索+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- [日本語]: DirectLiNGAM (Shimizu et al. 2011) による線形非ガウシアン因果探索。+--+-- ## 前提モデル+--+-- 観測 X ∈ ℝ^(n×p) が __線形 + acyclic + 非ガウシアン独立 noise__ な構造方程式+-- モデル X = B X + e に従う (B は適切な行/列順列で下三角化可能、 e の各成分は+-- 互いに独立かつ非ガウシアン)。 このとき DirectLiNGAM は ICA を経由せず、+-- 残差独立性 (差分相互情報量) の最大化で因果順序を 1 変数ずつ確定する。+--+-- ## アルゴリズム概要+--+-- 1. 候補集合 U = {0..p-1}、 因果順序 K = []+-- 2. p 回 loop:+-- a. searchCausalOrder で M(m) = -Σ_{j∈U,j≠m} min(0, ΔMI(x_m,x_j,r_{mj},r_{jm}))²+-- を最大化する m を選ぶ+-- b. U の各 i ≠ m について x_i ← residual(x_i, x_m) (m で残差化)+-- c. K に m を追加、 U から m を除く+-- 3. K から B 行列を OLS で組み上げる (causal order に従い順に回帰)+--+-- ## ΔMI (差分相互情報量)+--+-- 標準化後の x_i, x_j と残差 r_{ij}, r_{ji} (互いに片方を片方で回帰した残差)+-- に対し:+--+-- > ΔMI(x_i, x_j, r_{ij}, r_{ji}) = [H(x_j) + H(r_{ij}/σ_{r_{ij}})]+-- > - [H(x_i) + H(r_{ji}/σ_{r_{ji}})]+--+-- H は Hyvärinen (1998) の maximum entropy 近似:+--+-- > H(u) = (1 + log 2π)/2 - k1·(E[log cosh u] - γ)² - k2·(E[u·exp(-u²/2)])²+-- > k1 = 79.047, k2 = 7.4129, γ = 0.37457+--+-- ## リファレンス+--+-- Shimizu et al. (2011) "DirectLiNGAM: A direct method for learning a linear+-- non-Gaussian structural equation model", JMLR 12. Python 実装は+-- cdt15/lingam の `lingam/direct_lingam.py` で動作対応を確認した。+--+-- ## 落とし穴メモ+--+-- - 観測変数が __完全ガウシアン__ だと ΔMI ≈ 0 となり順序が一意決まらない。+-- ガウシアン応答には causal inference (介入効果) や PC algorithm 等の+-- 別手法を使う+-- - __n < 100__ だと entropy の sample 推定が不安定。 n ≥ 200 推奨+-- - 行列 B は __causal order の根本変数を 0 行目__ に置く慣習。 出力の+-- dlB[K[j], K[i]] = β_i (i < j) で表される (= 影響先 ← 影響元 規約)+--+-- [English]: Linear non-Gaussian causal discovery via DirectLiNGAM (Shimizu+-- et al. 2011).+--+-- ## Assumed model+--+-- The observations X ∈ ℝ^(n×p) follow a structural equation model X = B X + e that is __linear and acyclic with independent non-Gaussian noise__+-- (B can be made lower-triangular by an appropriate row\/column+-- permutation; each component of e is mutually independent and+-- non-Gaussian). DirectLiNGAM then determines the causal order one variable+-- at a time by maximizing residual independence (differential mutual+-- information), without going through ICA.+--+-- ## Algorithm overview+--+-- 1. Candidate set U = {0..p-1}, causal order K = []+-- 2. Loop p times:+-- a. In searchCausalOrder, pick the m that maximizes+-- M(m) = -Σ_{j∈U,j≠m} min(0, ΔMI(x_m,x_j,r_{mj},r_{jm}))²+-- b. For each i ≠ m in U, x_i ← residual(x_i, x_m) (residualize on m)+-- c. Append m to K, remove m from U+-- 3. Assemble the B matrix from K via OLS (regress in causal-order sequence)+--+-- ## ΔMI (differential mutual information)+--+-- For standardized x_i, x_j and residuals r_{ij}, r_{ji} (each regressed on+-- the other):+--+-- > ΔMI(x_i, x_j, r_{ij}, r_{ji}) = [H(x_j) + H(r_{ij}/σ_{r_{ij}})]+-- > - [H(x_i) + H(r_{ji}/σ_{r_{ji}})]+--+-- H is Hyvärinen's (1998) maximum entropy approximation:+--+-- > H(u) = (1 + log 2π)/2 - k1·(E[log cosh u] - γ)² - k2·(E[u·exp(-u²/2)])²+-- > k1 = 79.047, k2 = 7.4129, γ = 0.37457+--+-- ## Reference+--+-- Shimizu et al. (2011) "DirectLiNGAM: A direct method for learning a linear+-- non-Gaussian structural equation model", JMLR 12. Behavior was checked+-- against the Python implementation in cdt15/lingam's+-- `lingam/direct_lingam.py`.+--+-- ## Pitfall notes+--+-- - If the observed variables are __perfectly Gaussian__, ΔMI ≈ 0 and the+-- order is not uniquely determined. For Gaussian responses, use a+-- different technique such as causal inference (intervention effects) or+-- the PC algorithm.+-- - __n < 100__ makes the sample estimate of entropy unstable. n ≥ 200 is+-- recommended.+-- - By convention, matrix B is arranged so that __row 0 is the causal order's root variable__.+-- In the output, dlB[K[j], K[i]] = β_i (i < j) (= "influenced ← influencer" convention).+module Hanalyze.Model.LiNGAM.Direct+ ( DirectLiNGAMConfig (..)+ , DirectLiNGAMFit (..)+ , defaultDirectLiNGAMConfig+ , fitDirectLiNGAM+ , dlDAG+ -- helpers (re-export 不要時は internal だが、 単体テスト用に公開)+ , entropyApprox+ , diffMutualInfo+ , olsResidual+ , standardize+ ) where++import qualified Numeric.LinearAlgebra as LA+import Data.List (foldl')++import qualified Hanalyze.Model.DAG as DAG++-- ===========================================================================+-- 公開型+-- ===========================================================================++-- | [日本語]: DirectLiNGAM の設定。+-- [English]: DirectLiNGAM configuration.+data DirectLiNGAMConfig = DirectLiNGAMConfig+ { dlcPruneThr :: !Double+ -- ^ [日本語]: |B_ij| < 'dlcPruneThr' は隣接行列で 0 と扱う。 default 0.05。+ -- [English]: |B_ij| < 'dlcPruneThr' is treated as 0 in the adjacency+ -- matrix. Default 0.05.+ } deriving (Show)++defaultDirectLiNGAMConfig :: DirectLiNGAMConfig+defaultDirectLiNGAMConfig = DirectLiNGAMConfig+ { dlcPruneThr = 0.05+ }++-- | [日本語]: DirectLiNGAM の推定結果。+-- [English]: The estimation result of DirectLiNGAM.+data DirectLiNGAMFit = DirectLiNGAMFit+ { dlOrder :: ![Int]+ -- ^ [日本語]: 推定 causal order (topological)。 K[0] が最も外生的、 K[p-1] が+ -- 最も末端 (どの変数からも影響を受ける可能性のある変数)。+ -- [English]: The estimated causal order (topological). K[0] is the+ -- most exogenous, K[p-1] the most terminal (the variable most likely+ -- to be influenced by other variables).+ , dlB :: !(LA.Matrix Double)+ -- ^ [日本語]: 構造方程式係数行列 (p × p)。 X_i = Σ_j dlB[i, j] · X_j + e_i。+ -- causal order に従い適切な行/列順列で下三角化可能。+ -- [English]: The structural-equation coefficient matrix (p × p).+ -- X_i = Σ_j dlB[i, j] · X_j + e_i. Can be made lower-triangular by an+ -- appropriate row\/column permutation following the causal order.+ , dlAdjacency :: !(LA.Matrix Double)+ -- ^ [日本語]: |dlB| > dlcPruneThr の 0/1 マスク。 [English]: A 0\/1 mask+ -- for |dlB| > dlcPruneThr.+ , dlResiduals :: !(LA.Matrix Double)+ -- ^ [日本語]: 各サンプルの推定残差 e_i (n × p)。 独立性検定の事後評価に使う。+ -- [English]: The estimated residuals e_i per sample (n × p). Used for+ -- post-hoc independence-test evaluation.+ } deriving (Show)++-- ===========================================================================+-- 主アルゴリズム+-- ===========================================================================++-- | [日本語]: DirectLiNGAM を fit する。 X は n × p 行列 (各列 = 1 変数)。+-- [English]: Fits DirectLiNGAM. X is an n × p matrix (each column = 1+-- variable).+--+-- 計算量: 因果順序探索 O(p² · n) per iteration × p iterations = O(p³ · n)+-- (entropy 評価 + 残差化が dominant)。+-- | [日本語]: @DirectLiNGAMFit@ を 'Hanalyze.Model.DAG.DAG' 表現に変換 (threshold は+-- 元の 'dlcPruneThr' を再利用)。+-- [English]: Converts a @DirectLiNGAMFit@ to an+-- 'Hanalyze.Model.DAG.DAG' representation (reusing the original+-- 'dlcPruneThr' as the threshold).+dlDAG :: DirectLiNGAMConfig -> DirectLiNGAMFit -> DAG.DAG+dlDAG cfg fit = DAG.fromBMatrix (dlcPruneThr cfg) (dlB fit)++fitDirectLiNGAM :: DirectLiNGAMConfig -> LA.Matrix Double -> DirectLiNGAMFit+fitDirectLiNGAM cfg xs =+ let !p = LA.cols xs+ !n = LA.rows xs+ -- 各列を Vector に分解した可変リスト (residualize 用)+ cols0 :: [LA.Vector Double]+ cols0 = [ LA.flatten (xs LA.¿ [j]) | j <- [0 .. p - 1] ]+ -- 主 loop: cols / activeU / order を順次更新+ (order, _finalCols) = causalOrderLoop cols0 [0 .. p - 1] []+ -- 元の X から causal order に従い B 行列を OLS で組み立て+ bMat = estimateB xs order+ adjMat = buildAdjacency (dlcPruneThr cfg) bMat+ -- 残差: e = X - X·B^T (行ベクトル view、 単純な線形変換)+ resid = xs - xs LA.<> LA.tr bMat+ _ = n -- shadow warn 防止+ in DirectLiNGAMFit+ { dlOrder = order+ , dlB = bMat+ , dlAdjacency = adjMat+ , dlResiduals = resid+ }++-- | [日本語]: causal order を 1 つずつ確定する主ループ。+-- 引数:+-- cols : 現在の (残差化された) 列ベクトルのリスト (length p、 元 index で並ぶ)+-- activeU : まだ確定していない元 index のリスト+-- orderRev: これまでに確定した順序 (逆順、 後で reverse)+-- [English]: The main loop that fixes the causal order one entry at a+-- time.+-- Arguments:+-- cols : the current (residualized) list of column vectors (length+-- p, ordered by original index)+-- activeU : the list of original indices not yet fixed+-- orderRev: the order fixed so far (reversed; reversed again later)+causalOrderLoop+ :: [LA.Vector Double] -- 現状の列ベクトル+ -> [Int] -- active 集合+ -> [Int] -- 確定済 (逆順)+ -> ([Int], [LA.Vector Double])+causalOrderLoop cols activeU orderRev+ | null activeU = (reverse orderRev, cols)+ | length activeU == 1 =+ (reverse (head activeU : orderRev), cols)+ | otherwise =+ let !m = searchCausalOrder cols activeU+ xm = cols !! m+ -- m 以外の active で残差化+ colsNew = [ if j `elem` activeU && j /= m+ then olsResidual (cols !! j) xm+ else cols !! j+ | j <- [0 .. length cols - 1] ]+ activeNew = [ j | j <- activeU, j /= m ]+ in causalOrderLoop colsNew activeNew (m : orderRev)++-- | [日本語]: 候補集合 activeU から、 「最も外生的 (= 他から残差化された後の独立性が+-- 崩れにくい)」 index を 1 つ返す。+-- M(m) = -Σ_{j∈U, j≠m} min(0, ΔMI(x_m,x_j,r_{mj},r_{jm}))² を最大化。+-- [English]: From the candidate set activeU, returns the index that is+-- "most exogenous" (= its independence holds up best after being+-- residualized against the others).+-- Maximizes M(m) = -Σ_{j∈U, j≠m} min(0, ΔMI(x_m,x_j,r_{mj},r_{jm}))².+searchCausalOrder :: [LA.Vector Double] -> [Int] -> Int+searchCausalOrder cols activeU =+ let !scores = [ (m, score m) | m <- activeU ]+ score m =+ let xm = cols !! m+ xmStd = standardize xm+ contribs =+ [ let xj = cols !! j+ xjStd = standardize xj+ rmj = olsResidual xmStd xjStd -- xm を xj で残差化+ rjm = olsResidual xjStd xmStd -- xj を xm で残差化+ dmi = diffMutualInfo xmStd xjStd rmj rjm+ in min 0 dmi ** 2+ | j <- activeU, j /= m ]+ in negate (sum contribs)+ in fst (foldl' pickMax (head scores) (tail scores))+ where+ pickMax acc@(_, s0) cur@(_, s1)+ | s1 > s0 = cur+ | otherwise = acc++-- | [日本語]: 差分相互情報量 ΔMI = [H(xj) + H(rij/σ)] - [H(xi) + H(rji/σ)]。+-- 入力 xi/xj は標準化済、 rij/rji は __標準化前__ の残差。+-- [English]: The differential mutual information+-- ΔMI = [H(xj) + H(rij/σ)] - [H(xi) + H(rji/σ)]. The inputs xi\/xj are+-- already standardized; rij\/rji are residuals __before standardization__.+diffMutualInfo+ :: LA.Vector Double -- xi (標準化済)+ -> LA.Vector Double -- xj (標準化済)+ -> LA.Vector Double -- rij = xi - β xj 残差+ -> LA.Vector Double -- rji = xj - β xi 残差+ -> Double+diffMutualInfo xi xj rij rji =+ let !hxi = entropyApprox xi+ !hxj = entropyApprox xj+ !srij = stdSafe rij+ !srji = stdSafe rji+ !hrij = entropyApprox (LA.scale (1 / srij) rij)+ !hrji = entropyApprox (LA.scale (1 / srji) rji)+ in (hxj + hrij) - (hxi + hrji)+ where+ stdSafe v =+ let s = LA.norm_2 (v - LA.scalar (LA.sumElements v / fromIntegral (LA.size v)))+ / sqrt (fromIntegral (LA.size v))+ in if s > 1e-12 then s else 1.0++-- | [日本語]: Hyvärinen (1998) maximum entropy 近似:+-- H(u) = (1 + log 2π)/2 - k1·(E[log cosh u] - γ)² - k2·(E[u·exp(-u²/2)])²+-- u は事前に標準化されていることが前提。+-- [English]: Hyvärinen's (1998) maximum entropy approximation:+-- H(u) = (1 + log 2π)/2 - k1·(E[log cosh u] - γ)² - k2·(E[u·exp(-u²/2)])².+-- Assumes u has already been standardized.+entropyApprox :: LA.Vector Double -> Double+entropyApprox u =+ let !k1 = 79.047+ !k2 = 7.4129+ !gamma = 0.37457+ !n = fromIntegral (LA.size u) :: Double+ !logCosh = LA.sumElements (LA.cmap (\v -> log (cosh v)) u) / n+ !uExp = LA.sumElements (u * LA.cmap (\v -> exp (-v * v / 2)) u) / n+ in (1 + log (2 * pi)) / 2+ - k1 * (logCosh - gamma) ** 2+ - k2 * uExp ** 2++-- | [日本語]: OLS による残差: r = xi - (Cov(xi,xj) / Var(xj)) · xj+-- [English]: The OLS residual: r = xi - (Cov(xi,xj) \/ Var(xj)) · xj.+olsResidual :: LA.Vector Double -> LA.Vector Double -> LA.Vector Double+olsResidual xi xj =+ let !n = fromIntegral (LA.size xi) :: Double+ !mxi = LA.sumElements xi / n+ !mxj = LA.sumElements xj / n+ !ci = xi - LA.scalar mxi+ !cj = xj - LA.scalar mxj+ !cov = ci `LA.dot` cj / n+ !var = cj `LA.dot` cj / n+ !beta = if var > 1e-12 then cov / var else 0+ in xi - LA.scale beta xj++-- | [日本語]: 中心化 + 標準偏差で割る (zero-mean, unit-variance)。+-- [English]: Centers and divides by the standard deviation (zero-mean,+-- unit-variance).+standardize :: LA.Vector Double -> LA.Vector Double+standardize v =+ let !n = fromIntegral (LA.size v) :: Double+ !mu = LA.sumElements v / n+ !c = v - LA.scalar mu+ !s = sqrt (c `LA.dot` c / n)+ !sd = if s > 1e-12 then s else 1.0+ in LA.scale (1 / sd) c++-- ===========================================================================+-- B 行列 + 隣接行列+-- ===========================================================================++-- | [日本語]: causal order に従い B 行列を OLS で組み立てる。+-- B[K[j], K[i]] = OLS 回帰 X[:,K[j]] ~ X[:,K[0..j-1]] の i 番目係数。+-- [English]: Assembles the B matrix via OLS following the causal order.+-- B[K[j], K[i]] = the i-th coefficient of the OLS regression+-- X[:,K[j]] ~ X[:,K[0..j-1]].+estimateB :: LA.Matrix Double -> [Int] -> LA.Matrix Double+estimateB xs order =+ let !p = LA.cols xs+ bRows = [ buildRow j | j <- [0 .. p - 1] ]+ buildRow j =+ let kj = order !! j+ -- 影響元候補: order の j より前+ parents = take j order+ in if null parents+ then LA.fromList (replicate p 0)+ else+ let parentMat = LA.fromColumns+ [ LA.flatten (xs LA.¿ [pIdx]) | pIdx <- parents ]+ target = LA.flatten (xs LA.¿ [kj])+ beta = olsBeta parentMat target+ coefVec = replicate p 0+ -- beta を parent 位置に散布+ updates = zip parents (LA.toList beta)+ filled = foldl' (\acc (idx, v) -> setAt acc idx v) coefVec updates+ in LA.fromList filled+ -- 行は K の順序、 列は元 variable index。+ -- bRows[j] は variable K[j] の行ベクトル → reorder で元 variable index 順に+ origOrderMat = LA.fromRows+ [ bRows !! posInOrder i | i <- [0 .. p - 1] ]+ posInOrder i = case lookup i (zip order [0 ..]) of+ Just k -> k+ Nothing -> 0 -- unreachable+ in origOrderMat++-- | [日本語]: OLS 係数: β = (XᵀX)⁻¹ Xᵀy+-- [English]: The OLS coefficients: β = (XᵀX)⁻¹ Xᵀy.+olsBeta :: LA.Matrix Double -> LA.Vector Double -> LA.Vector Double+olsBeta x y =+ let xtx = LA.tr x LA.<> x+ xty = LA.tr x LA.#> y+ in LA.flatten (LA.linearSolveLS xtx (LA.asColumn xty))++setAt :: [a] -> Int -> a -> [a]+setAt xs i v = take i xs ++ [v] ++ drop (i + 1) xs++-- | [日本語]: |B_ij| > threshold で 1、 以外 0 の隣接行列。 対角は 0 に固定。+-- [English]: The adjacency matrix: 1 when |B_ij| > threshold, otherwise 0.+-- The diagonal is fixed at 0.+buildAdjacency :: Double -> LA.Matrix Double -> LA.Matrix Double+buildAdjacency thr b =+ let !p = LA.rows b+ f i j+ | i == j = 0+ | abs (LA.atIndex b (i, j)) > thr = 1+ | otherwise = 0+ in LA.build (p, p) (\i j -> f (round i) (round j) :: Double)
+ src/Hanalyze/Model/LiNGAM/ICA.hs view
@@ -0,0 +1,317 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE BangPatterns #-}+-- |+-- Module : Hanalyze.Model.LiNGAM.ICA+-- Description : ICA-LiNGAM (Shimizu 2006、原典版) by FastICA + Hungarian 順列+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- [日本語]: ICA-LiNGAM (Shimizu et al. 2006、 LiNGAM の原典版) by FastICA。+--+-- ## アルゴリズム+--+-- 1. 観測 X (n × p) に対し FastICA で __分離行列 W__ (= ICA unmixing) を求める+-- (元座標、 'Hanalyze.Math.ICA.icaUnmixing')+-- 2. __A = pinv(W)__ を計算 (X = S · Aᵀ + mean)+-- 3. __行/列順列で下三角化__:+-- a. A の絶対値の __逆数__ をコスト行列とし、 行・列順列で対角要素を+-- 絶対値最大に揃える Hungarian-like (本実装は近似貪欲)+-- b. 順列適用後の A を対角要素で正規化、 B = I - A_perm⁻¹+-- c. B の下三角化のための __行順列__ を別途決定 (= causal order)+-- 4. B 行列を pruning して隣接行列を返す+--+-- ## DirectLiNGAM との違い+--+-- DirectLiNGAM は ICA 不要で残差独立性 + 1 変数ずつ確定。 ICA-LiNGAM は ICA+-- (FastICA) で全成分を同時推定 → 順列で因果順序を後付けで決める。 ICA の+-- 収束性に依存するが、 因子数が多いときは並列度で有利な場合がある。+--+-- 行/列順列は __Hungarian (Kuhn-Munkres, O(p³))__ で大域最適化する+-- ('Hanalyze.Math.Hungarian')。 cdt15/lingam の Python 実装は+-- @scipy.optimize.linear_sum_assignment(1 / |W|)@ で同等のことをしており、+-- コスト関数も @1 / (|W| + ε)@ で揃えている。 旧来の貪欲版 ('greedyAssignRows')+-- は @ilcUseHungarian = False@ で復元可能 (回帰確認・ベンチ比較用)。+--+-- ## リファレンス+--+-- Shimizu et al. (2006) "A Linear Non-Gaussian Acyclic Model for Causal+-- Discovery", JMLR 7. Python 実装は cdt15/lingam の `lingam/ica_lingam.py`。+--+-- [English]: ICA-LiNGAM (Shimizu et al. 2006, the original LiNGAM+-- formulation) via FastICA.+--+-- ## Algorithm+--+-- 1. For observations X (n × p), obtain the __separating matrix W__+-- (= ICA unmixing) by FastICA, in the original coordinates+-- ('Hanalyze.Math.ICA.icaUnmixing').+-- 2. Compute __A = pinv(W)__ (X = S · Aᵀ + mean).+-- 3. __Lower-triangularize by row\/column permutation__:+-- a. Use the __reciprocal__ of |A| as the cost matrix and align the+-- diagonal entries to the largest absolute values by row\/column+-- permutation, Hungarian-like (this implementation is an approximate+-- greedy one).+-- b. Normalize the permuted A by its diagonal entries, B = I - A_perm⁻¹.+-- c. Separately determine the __row permutation__ that lower-triangularizes+-- B (= the causal order).+-- 4. Prune the B matrix and return the adjacency matrix.+--+-- ## Difference from DirectLiNGAM+--+-- DirectLiNGAM needs no ICA and fixes one variable at a time via residual+-- independence. ICA-LiNGAM estimates all components simultaneously by ICA+-- (FastICA) and then decides the causal order afterwards by permutation. It+-- depends on the convergence of ICA, but can be advantageous in parallelism+-- when the number of factors is large.+--+-- The row\/column permutation is globally optimized by+-- __Hungarian (Kuhn-Munkres, O(p³))__ ('Hanalyze.Math.Hungarian').+-- The Python implementation in cdt15\/lingam does the equivalent with+-- @scipy.optimize.linear_sum_assignment(1 / |W|)@, and the cost function is+-- matched here as @1 / (|W| + ε)@. The legacy greedy version+-- ('greedyAssignRows') can be restored with @ilcUseHungarian = False@ (for+-- regression checks and benchmark comparison).+--+-- ## References+--+-- Shimizu et al. (2006) "A Linear Non-Gaussian Acyclic Model for Causal+-- Discovery", JMLR 7. The Python implementation is `lingam/ica_lingam.py`+-- in cdt15\/lingam.+module Hanalyze.Model.LiNGAM.ICA+ ( ICALiNGAMConfig (..)+ , ICALiNGAMFit (..)+ , fitICALiNGAMPure+ , defaultICALiNGAMConfig+ , fitICALiNGAM+ , ilDAG+ ) where++import qualified Numeric.LinearAlgebra as LA+import qualified Data.Vector.Unboxed as VU+import Data.List (sortBy)+import Data.Ord (comparing, Down (..))++import qualified Hanalyze.Math.ICA as ICA+import qualified Hanalyze.Math.Hungarian as Hung+import qualified Hanalyze.Model.DAG as DAG++-- ===========================================================================+-- 設定 / 結果+-- ===========================================================================++data ICALiNGAMConfig = ICALiNGAMConfig+ { ilcPruneThr :: !Double+ , ilcICACfg :: !ICA.ICAConfig+ , ilcUseHungarian :: !Bool+ -- ^ [日本語]: True: 行順列を Hungarian (O(p³)) で大域最適化 (default、 推奨)。+ -- False: 旧来の貪欲版を使う (回帰比較・ベンチ用)。+ -- [English]: True: globally optimize the row permutation with Hungarian+ -- (O(p³)) (default, recommended). False: use the legacy greedy version+ -- (for regression comparison and benchmarking).+ } deriving (Show)++defaultICALiNGAMConfig :: ICALiNGAMConfig+defaultICALiNGAMConfig = ICALiNGAMConfig+ { ilcPruneThr = 0.05+ , ilcICACfg = ICA.defaultICAConfig+ , ilcUseHungarian = True+ }++data ICALiNGAMFit = ICALiNGAMFit+ { ilOrder :: ![Int]+ , ilB :: !(LA.Matrix Double)+ , ilAdjacency :: !(LA.Matrix Double)+ , ilICAResult :: !ICA.ICAResult+ } deriving (Show)++-- ===========================================================================+-- 主実装+-- ===========================================================================++fitICALiNGAM :: ICALiNGAMConfig -> LA.Matrix Double -> IO ICALiNGAMFit+fitICALiNGAM cfg x = do+ ica <- ICA.fitICA (ilcICACfg cfg) x+ pure (assembleICALiNGAM cfg ica)++-- | [日本語]: 'fitICALiNGAM' の __seed 純粋版__ (@df |->@ 用)。 @fitICAPure@ (seed) で+-- FastICA を回す。 同 seed で IO 版とビット一致。+-- [English]: The __seed-based pure version__ of 'fitICALiNGAM' (for+-- @df |->@). Runs FastICA via @fitICAPure@ (seed). Bit-identical to the IO+-- version for the same seed.+fitICALiNGAMPure :: ICALiNGAMConfig -> LA.Matrix Double -> ICALiNGAMFit+fitICALiNGAMPure cfg x = assembleICALiNGAM cfg (ICA.fitICAPure (ilcICACfg cfg) x)++-- | [日本語]: ICA 結果 → 'ICALiNGAMFit' の純粋組み立て (行順列 → 正規化 → 下三角化 → adjacency)。+-- [English]: Pure assembly of an 'ICALiNGAMFit' from an ICA result (row+-- permutation → normalization → lower-triangularization → adjacency).+assembleICALiNGAM :: ICALiNGAMConfig -> ICA.ICAResult -> ICALiNGAMFit+assembleICALiNGAM cfg ica =+ let !w = ICA.icaUnmixing ica -- (p × p)+ !p = LA.rows w+ -- step 3a: 対角絶対値最大化の行順列を決定。 Hungarian は大域最適、+ -- 貪欲は p > 10 でしばしば劣化する (cdt15/lingam も Hungarian 採用)。+ !rowPerm = if ilcUseHungarian cfg+ then hungarianAssignRows w+ else greedyAssignRows w+ !wPerm1 = permuteRows w rowPerm+ -- step 3b: 各行を対角で正規化+ !wNorm = normalizeDiag wPerm1+ -- B' = I - W_norm+ !bPrime = LA.ident p - wNorm+ -- step 3c: bPrime の行順列を causal order に並べる+ -- 下三角化: 順列の絶対値和が下三角寄りになるよう貪欲に並べ替え+ !causal = causalOrderFromTriangle bPrime+ -- causal order で再順列した B を返す+ !bReorder = permuteRowsCols bPrime causal causal+ -- 元 variable index に戻す+ -- bPrime[i, j] は permuted index 上の値、 rowPerm を逆引きする必要あり+ !bFinal = restoreOriginalIndex p bPrime rowPerm causal+ !adj = adjMatrix (ilcPruneThr cfg) bFinal+ _ = bReorder -- 内部debug 用、 未使用+ in ICALiNGAMFit+ { ilOrder = mapPerm causal rowPerm+ , ilB = bFinal+ , ilAdjacency = adj+ , ilICAResult = ica+ }++-- | [日本語]: DAG への変換+-- [English]: Conversion to a DAG.+ilDAG :: ICALiNGAMConfig -> ICALiNGAMFit -> DAG.DAG+ilDAG cfg fit = DAG.fromBMatrix (ilcPruneThr cfg) (ilB fit)++-- ===========================================================================+-- 内部: 順列ヘルパ+-- ===========================================================================++-- | [日本語]: Hungarian による行順列決定。 コスト C[i, j] = 1 / (|W[i, j]| + ε) で+-- 'Hung.hungarianMin' を呼び、 row i → col j の割当を得てから+-- perm[j] = i に反転する (col j に row i を置く)。+-- cdt15/lingam の Python 実装 (scipy linear_sum_assignment(1/|W|)) と同型。+-- [English]: Determines the row permutation by the Hungarian algorithm.+-- Calls 'Hung.hungarianMin' with cost C[i, j] = 1 / (|W[i, j]| + ε), obtains+-- the assignment row i → col j, and then inverts it into perm[j] = i (place+-- row i at col j). Isomorphic to the Python implementation in cdt15\/lingam+-- (scipy linear_sum_assignment(1/|W|)).+hungarianAssignRows :: LA.Matrix Double -> [Int]+hungarianAssignRows w =+ let p = LA.rows w+ eps = 1.0e-12+ cost = LA.build (p, p)+ (\i j -> 1.0 / (abs (LA.atIndex w (round i, round j)) + eps)+ :: Double)+ assign = Hung.hungarianMin cost -- assign[i] = j (row i → col j)+ pairs = sortBy (comparing fst)+ [ (assign VU.! i, i) | i <- [0 .. p - 1] ]+ -- (col j, row i)+ in map snd pairs -- perm[j] = i++-- | [日本語]: 行順列の貪欲決定: 各列の絶対値最大要素を見て、 行と列を 1-1 対応させる+-- greedy assignment (Hungarian の近似版)。 戻り値 perm の意味:+-- 「permuted index j に元 row index perm[j] を持ってくる」 (= rows ordering)。+-- [English]: Greedy determination of the row permutation: a greedy+-- assignment that matches rows and columns one-to-one by looking at the+-- largest-magnitude entry of each column (an approximation of Hungarian).+-- The returned perm means "bring the original row index perm[j] to permuted+-- index j" (= rows ordering).+greedyAssignRows :: LA.Matrix Double -> [Int]+greedyAssignRows w =+ let p = LA.rows w+ -- 候補を (元 row i, 元 col j, abs value) として絶対値降順に並べる+ candidates :: [((Int, Int), Double)]+ candidates = sortBy (comparing (Down . snd))+ [ ((i, j), abs (LA.atIndex w (i, j)))+ | i <- [0 .. p - 1], j <- [0 .. p - 1] ]+ -- 貪欲: row と col を使用済にしながら (col j に row i を割当て)+ assign :: [Int] -> [Int] -> [((Int, Int), Double)] -> [(Int, Int)]+ assign _ _ [] = []+ assign usedRows usedCols (((i, j), _):rest)+ | i `elem` usedRows || j `elem` usedCols = assign usedRows usedCols rest+ | otherwise = (j, i) : assign (i:usedRows) (j:usedCols) rest+ pairs = assign [] [] candidates -- (col j, row i) のペア+ sortedPairs = sortBy (comparing fst) pairs -- col 昇順+ perm = map snd sortedPairs -- perm[j] = i+ in if length perm == p+ then perm+ else [0 .. p - 1] -- fallback++-- | [日本語]: 行を perm で並べ替える (perm[i] = 元 index)。+-- [English]: Reorders the rows by perm (perm[i] = the original index).+permuteRows :: LA.Matrix Double -> [Int] -> LA.Matrix Double+permuteRows m perm = m LA.? perm++-- | [日本語]: 各行を対角要素で正規化する (W → W / diag(W))。+-- [English]: Normalizes each row by its diagonal entry (W → W / diag(W)).+normalizeDiag :: LA.Matrix Double -> LA.Matrix Double+normalizeDiag w =+ let p = LA.rows w+ diags = [ LA.atIndex w (i, i) | i <- [0 .. p - 1] ]+ f i j =+ let d = diags !! i+ v = LA.atIndex w (i, j)+ in if abs d > 1e-12 then v / d else v+ in LA.build (p, p) (\i j -> f (round i) (round j) :: Double)++-- | [日本語]: B から下三角化のための行順列を貪欲に決める。+-- 各行の非零要素数が少ない行 (根) を先に置く戦略。+-- [English]: Greedily determines the row permutation that+-- lower-triangularizes B. The strategy places rows with fewer nonzero+-- entries (the roots) first.+causalOrderFromTriangle :: LA.Matrix Double -> [Int]+causalOrderFromTriangle b =+ let p = LA.rows b+ scoreRow i =+ sum [ abs (LA.atIndex b (i, j))+ | j <- [0 .. p - 1], j /= i ]+ sorted = sortBy (comparing snd)+ [ (i, scoreRow i) | i <- [0 .. p - 1] ]+ in map fst sorted++-- | [日本語]: 行と列を同じ perm で並び替え (DAG 構造を保つ)。+-- [English]: Reorders rows and columns by the same perm (preserving the DAG+-- structure).+permuteRowsCols :: LA.Matrix Double -> [Int] -> [Int] -> LA.Matrix Double+permuteRowsCols m rp cp =+ let mR = m LA.? rp+ mTr = LA.tr mR LA.? cp+ in LA.tr mTr++-- | [日本語]: 元の variable index に戻す。+-- permuted index 上での B → original index 上での B。+-- [English]: Restores the original variable indices. B on permuted indices+-- → B on original indices.+restoreOriginalIndex+ :: Int+ -> LA.Matrix Double -- B_prime (permuted index 上)+ -> [Int] -- rowPerm: permuted_i ← original_rowPerm[i]+ -> [Int] -- causal: permuted index 上での causal order+ -> LA.Matrix Double+restoreOriginalIndex p bPrime rowPerm _causal =+ -- bPrime は rowPerm で permuted されている。 inverse perm で元に戻す。+ let invPerm = invertPerm rowPerm+ f i j = LA.atIndex bPrime (invPerm !! i, invPerm !! j)+ in LA.build (p, p) (\i j -> f (round i) (round j) :: Double)++invertPerm :: [Int] -> [Int]+invertPerm perm =+ let p = length perm+ pairs = zip perm [0 ..]+ sorted = sortBy (comparing fst) pairs+ in map snd sorted ++ replicate (p - length sorted) 0++-- | [日本語]: original index 上での causal order (= permuted causal を rowPerm で戻す)+-- [English]: The causal order on original indices (= mapping the permuted+-- causal order back through rowPerm).+mapPerm :: [Int] -> [Int] -> [Int]+mapPerm causal rowPerm = map (rowPerm !!) causal++-- | [日本語]: adjacency 行列 (|B| > thr のマスク)+-- [English]: The adjacency matrix (the mask of |B| > thr).+adjMatrix :: Double -> LA.Matrix Double -> LA.Matrix Double+adjMatrix thr b =+ let p = LA.rows b+ f i j+ | i == j = 0+ | abs (LA.atIndex b (i, j)) > thr = 1+ | otherwise = 0+ in LA.build (p, p) (\i j -> f (round i) (round j) :: Double)
+ src/Hanalyze/Model/LiNGAM/MultiGroup.hs view
@@ -0,0 +1,238 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE BangPatterns #-}+-- |+-- Module : Hanalyze.Model.LiNGAM.MultiGroup+-- Description : MultiGroupLiNGAM (Shimizu 2012、群間で共通 DAG 構造・係数値のみ異なる LiNGAM 拡張)+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- [日本語]: MultiGroupLiNGAM (Shimizu 2012): 複数群 (group) で __共通の DAG 構造__ を+-- 仮定し、 群間で係数値は異なる可能性を許す LiNGAM 拡張。+--+-- ## モデル+--+-- 群 g = 1..G について、 観測 X^(g) は同じ causal order に従う SEM:+--+-- > X^(g) = B^(g) · X^(g) + e^(g)+--+-- 各 B^(g) の非零パターン (= DAG 構造) は __全群共通__ を仮定するが、 値は+-- 群ごとに異なってよい。 これは半導体現場の「異なる工場 / 装置号機 / 世代で+-- 同じ因果構造、 効き量だけ違う」 という想定とマッチする。+--+-- ## アルゴリズム+--+-- 1. 各群 X^(g) について @fitDirectLiNGAM@ を独立に実行 → B^(g)、 K^(g)+-- 2. 全群の K^(g) を集約して __多数決で共通 causal order__ を確定+-- (本実装: 各位置 j の頻度最大ノードを選び、 不一致時は位置 j の総合的+-- 平均スコアを再計算)+-- 3. 共通 order に従い、 各群で再度 OLS で B^(g) を組み直す+-- 4. __共通 adjacency__: 各群で |B^(g)[i, j]| > thr となるエッジ数が+-- 全群のうち過半数なら採用+--+-- ## リファレンス+--+-- Shimizu (2012) "Joint estimation of linear non-Gaussian acyclic models",+-- Neurocomputing 81. Python 実装は cdt15/lingam の `lingam/multi_group_lingam.py`。+--+-- [English]: MultiGroupLiNGAM (Shimizu 2012): a LiNGAM extension that assumes+-- a __common DAG structure__ across multiple groups while allowing the+-- coefficient values to differ between groups.+--+-- ## Model+--+-- For each group g = 1..G, the observations X^(g) follow the same SEM under+-- a common causal order:+--+-- > X^(g) = B^(g) · X^(g) + e^(g)+--+-- The non-zero pattern of each B^(g) (= DAG structure) is assumed to be+-- __common to all groups__, but the values may differ per group. This+-- matches the semiconductor-fab scenario of "different factories \/ tool+-- numbers \/ generations sharing the same causal structure but differing+-- only in effect magnitude."+--+-- ## Algorithm+--+-- 1. Run @fitDirectLiNGAM@ independently on each group's X^(g) → B^(g), K^(g)+-- 2. Aggregate all groups' K^(g) to determine the __common causal order by majority vote__+-- (this implementation: pick the most frequent node at+-- each position j, and on ties recompute the overall average score for+-- position j)+-- 3. Refit B^(g) for each group by OLS again, following the common order+-- 4. __Common adjacency__: adopt an edge if the count of groups where+-- |B^(g)[i, j]| > thr exceeds the majority threshold across all groups+--+-- ## Reference+--+-- Shimizu (2012) "Joint estimation of linear non-Gaussian acyclic models",+-- Neurocomputing 81. The Python implementation is cdt15/lingam's+-- `lingam/multi_group_lingam.py`.+module Hanalyze.Model.LiNGAM.MultiGroup+ ( MultiGroupConfig (..)+ , MultiGroupFit (..)+ , defaultMultiGroupConfig+ , fitMultiGroupLiNGAM+ , mgCommonDAG+ ) where++import qualified Numeric.LinearAlgebra as LA+import Data.List (foldl', sort, group, sortBy)+import Data.Ord (comparing, Down (..))++import qualified Hanalyze.Model.LiNGAM.Direct as DL+import qualified Hanalyze.Model.DAG as DAG++-- ===========================================================================+-- 設定 / 結果+-- ===========================================================================++data MultiGroupConfig = MultiGroupConfig+ { mgcDirectCfg :: !DL.DirectLiNGAMConfig+ , mgcMajority :: !Double+ -- ^ [日本語]: adjacency 多数決閾値 (0..1)、 default 0.5。 [English]: Adjacency+ -- majority-vote threshold (0..1), default 0.5.+ } deriving (Show)++defaultMultiGroupConfig :: MultiGroupConfig+defaultMultiGroupConfig = MultiGroupConfig+ { mgcDirectCfg = DL.defaultDirectLiNGAMConfig+ , mgcMajority = 0.5+ }++data MultiGroupFit = MultiGroupFit+ { mgGroupFits :: ![DL.DirectLiNGAMFit]+ -- ^ [日本語]: 各群独立 fit 結果。 [English]: Independent fit results per group.+ , mgCommonOrder :: ![Int]+ -- ^ [日本語]: 多数決で確定した共通 causal order。 [English]: The common+ -- causal order determined by majority vote.+ , mgGroupBMats :: ![LA.Matrix Double]+ -- ^ [日本語]: 共通 order で再 fit した各群 B 行列。 [English]: Each group's B+ -- matrix, refit under the common order.+ , mgCommonAdj :: !(LA.Matrix Double)+ -- ^ [日本語]: 多数決による共通 adjacency マスク (0/1)。 [English]: The common+ -- adjacency mask (0\/1) determined by majority vote.+ } deriving (Show)++-- ===========================================================================+-- 主実装+-- ===========================================================================++fitMultiGroupLiNGAM :: MultiGroupConfig -> [LA.Matrix Double] -> MultiGroupFit+fitMultiGroupLiNGAM cfg groups =+ let !groupFits = [ DL.fitDirectLiNGAM (mgcDirectCfg cfg) g | g <- groups ]+ !p = if null groupFits then 0 else LA.cols (DL.dlB (head groupFits))+ !commonOrd = majorityOrder p (map DL.dlOrder groupFits)+ -- 共通 order に従って各群で B を再度 OLS で組み立てる+ !commonBs = [ refitWithOrder commonOrd g | g <- groups ]+ !commonAdj = majorityAdjacency+ (mgcMajority cfg)+ (DL.dlcPruneThr (mgcDirectCfg cfg))+ commonBs+ in MultiGroupFit+ { mgGroupFits = groupFits+ , mgCommonOrder = commonOrd+ , mgGroupBMats = commonBs+ , mgCommonAdj = commonAdj+ }++-- | [日本語]: 共通 adjacency に基づく DAG 表現。 重みは全群 B の平均を使う。+-- [English]: A DAG representation based on the common adjacency. The+-- weights use the average of all groups' B.+mgCommonDAG :: MultiGroupFit -> DAG.DAG+mgCommonDAG fit =+ let !bs = mgGroupBMats fit+ !adj = mgCommonAdj fit+ !p = LA.rows adj+ !g = fromIntegral (length bs) :: Double+ !meanB = LA.scale (1 / g) (foldl' (+) (LA.konst 0 (p, p)) bs)+ f i j+ | i == j = 0+ | LA.atIndex adj (i, j) == 0 = 0+ | otherwise = LA.atIndex meanB (i, j)+ w = LA.build (p, p) (\i j -> f (round i) (round j) :: Double)+ in DAG.mkDAG w++-- ===========================================================================+-- 内部+-- ===========================================================================++-- | [日本語]: 多数決で共通 causal order を決める。 各位置 j で最頻 node を取り、+-- 重複が出たら未確定 node を残りから追加する fallback。+-- [English]: Determines the common causal order by majority vote. Takes the+-- most frequent node at each position j, and falls back to adding an+-- undetermined node from the remainder when a duplicate occurs.+majorityOrder :: Int -> [[Int]] -> [Int]+majorityOrder p orders+ | null orders = [0 .. p - 1]+ | otherwise =+ let posCount j = [ ord !! j | ord <- orders, length ord > j ]+ mostFreq xs =+ let !grouped = sortBy (comparing (Down . length))+ (group (sort xs))+ in case grouped of+ ((h:_):_) -> h+ _ -> 0+ go acc unused j+ | j >= p = reverse acc+ | otherwise =+ let !cand = mostFreq (posCount j)+ in if cand `elem` unused+ then go (cand : acc) (filter (/= cand) unused) (j + 1)+ else+ -- fallback: 残りから一番低 index+ case unused of+ [] -> reverse acc+ (h : _) ->+ go (h : acc) (filter (/= h) unused) (j + 1)+ in go [] [0 .. p - 1] 0++-- | [日本語]: 指定 causal order に従い X から B を OLS で組み立て直す。+-- [English]: Rebuilds B from X by OLS, following the given causal order.+refitWithOrder :: [Int] -> LA.Matrix Double -> LA.Matrix Double+refitWithOrder order x =+ let !p = LA.cols x+ mkRow j =+ let kj = order !! j+ parents = take j order+ in if null parents+ then LA.fromList (replicate p 0)+ else+ let pm = LA.fromColumns+ [ LA.flatten (x LA.¿ [pIdx]) | pIdx <- parents ]+ y = LA.flatten (x LA.¿ [kj])+ beta = LA.flatten+ (LA.linearSolveLS (LA.tr pm LA.<> pm)+ (LA.asColumn (LA.tr pm LA.#> y)))+ updates = zip parents (LA.toList beta)+ coefV = replicate p 0+ filled = foldl' (\acc (i, v) -> set acc i v) coefV updates+ in LA.fromList filled+ bRows = [ mkRow j | j <- [0 .. p - 1] ]+ pos i = case lookup i (zip order [0 ..]) of+ Just k -> k+ Nothing -> 0+ origOrderMat = LA.fromRows [ bRows !! pos i | i <- [0 .. p - 1] ]+ in origOrderMat+ where+ set xs i v = take i xs ++ [v] ++ drop (i + 1) xs++-- | [日本語]: 多数決による共通 adjacency: |B^(g)[i, j]| > thr が 全群中 majorityRatio+-- 以上の比率で起こったら 1。+-- [English]: Common adjacency by majority vote: 1 if |B^(g)[i, j]| > thr+-- occurs at a rate at or above majorityRatio across all groups.+majorityAdjacency+ :: Double -- majority ratio (0..1)+ -> Double -- B threshold+ -> [LA.Matrix Double]+ -> LA.Matrix Double+majorityAdjacency majRatio thr bs =+ let !p = LA.rows (head bs)+ !g = fromIntegral (length bs) :: Double+ f i j+ | i == j = 0+ | otherwise =+ let cnt = length [ () | b <- bs+ , abs (LA.atIndex b (i, j)) > thr ]+ rate = fromIntegral cnt / g+ in if rate >= majRatio then 1 else 0+ in LA.build (p, p) (\i j -> f (round i) (round j) :: Double)
+ src/Hanalyze/Model/LiNGAM/Pairwise.hs view
@@ -0,0 +1,139 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE BangPatterns #-}+-- |+-- Module : Hanalyze.Model.LiNGAM.Pairwise+-- Description : Pairwise LiNGAM (Hyvärinen-Smith 2013、2 変数間の因果方向推定)+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- [日本語]: Pairwise LiNGAM: 2 変数間の因果方向 (x → y か y → x か) 推定。+--+-- ## アルゴリズム (Hyvärinen-Smith 2013)+--+-- 標準化された (x, y) について、 非ガウシアン独立性に基づき:+--+-- R(x → y) = - Cov(x³, y) · sign(Cov(x, y)) + Cov(x, y³)+--+-- の符号で方向を決定する近似的測度 (LIM, likelihood ratio approximation)。+--+-- - R > 0 → x → y+-- - R < 0 → y → x+-- - |R| 小 → 判定不能 (ガウシアン近接 or 弱依存)+--+-- 軽量で 2 変数の方向推定に直接使える。 3 変数以上には @DirectLiNGAM@ を使う。+--+-- ## リファレンス+--+-- Hyvärinen, A. & Smith, S. M. (2013) "Pairwise likelihood ratios for+-- estimation of non-Gaussian structural equation models", JMLR 14.+-- Python 実装は cdt15/lingam の `lingam/lim.py` (LIM = Likelihood-based+-- Independence Measure)。+--+-- [English]: Pairwise LiNGAM: estimates the causal direction between two+-- variables (x → y or y → x).+--+-- ## Algorithm (Hyvärinen-Smith 2013)+--+-- For standardized (x, y), based on non-Gaussian independence:+--+-- R(x → y) = - Cov(x³, y) · sign(Cov(x, y)) + Cov(x, y³)+--+-- is an approximate measure (LIM, likelihood ratio approximation) whose+-- sign determines direction.+--+-- - R > 0 → x → y.+-- - R < 0 → y → x.+-- - |R| small → inconclusive (near-Gaussian or weak dependence).+--+-- Lightweight and usable directly for two-variable direction estimation.+-- Use @DirectLiNGAM@ for three or more variables.+--+-- ## Reference+--+-- Hyvärinen, A. & Smith, S. M. (2013) "Pairwise likelihood ratios for+-- estimation of non-Gaussian structural equation models", JMLR 14. The+-- Python implementation is cdt15/lingam's `lingam/lim.py` (LIM =+-- Likelihood-based Independence Measure).+module Hanalyze.Model.LiNGAM.Pairwise+ ( PairwiseDirection (..)+ , PairwiseResult (..)+ , pairwiseLiNGAM+ , pairwiseScore+ ) where++import qualified Numeric.LinearAlgebra as LA++-- ===========================================================================+-- 型+-- ===========================================================================++data PairwiseDirection+ = XtoY -- ^ [日本語]: x → y。 [English]: x → y.+ | YtoX -- ^ [日本語]: y → x。 [English]: y → x.+ | Inconclusive -- ^ [日本語]: |score| < threshold。 [English]: |score| < threshold.+ deriving (Show, Eq)++data PairwiseResult = PairwiseResult+ { prScore :: !Double -- ^ [日本語]: R(x → y) の値、 符号で方向決定。 [English]: The value of R(x → y); its sign determines the direction.+ , prDirection :: !PairwiseDirection+ , prMagnitude :: !Double -- ^ [日本語]: |score|、 confidence の代理。 [English]: |score|, a proxy for confidence.+ } deriving (Show)++-- ===========================================================================+-- 実装+-- ===========================================================================++-- | [日本語]: Pairwise LiNGAM の主関数。 threshold 未満は Inconclusive。+-- [English]: The main Pairwise LiNGAM function. Below the threshold,+-- returns Inconclusive.+pairwiseLiNGAM+ :: Double -- threshold (default 0.0 = 符号だけで判定)+ -> LA.Vector Double -- x+ -> LA.Vector Double -- y+ -> PairwiseResult+pairwiseLiNGAM thr x y =+ let !s = pairwiseScore x y+ !mag = abs s+ !dir+ | mag < thr = Inconclusive+ | s > 0 = XtoY+ | otherwise = YtoX+ in PairwiseResult { prScore = s, prDirection = dir, prMagnitude = mag }++-- | [日本語]: スコア R = -Cov(x³, y)·sign(Cov(x,y)) + Cov(x, y³)+-- x, y は内部で標準化される (zero-mean、 unit-variance)。+-- [English]: The score R = -Cov(x³, y)·sign(Cov(x,y)) + Cov(x, y³). x, y+-- are standardized internally (zero-mean, unit-variance).+pairwiseScore :: LA.Vector Double -> LA.Vector Double -> Double+pairwiseScore xRaw yRaw =+ let !x = standardize xRaw+ !y = standardize yRaw+ !x3 = x * x * x+ !y3 = y * y * y+ !cov_x_y = covar x y+ !cov_x3_y = covar x3 y+ !cov_x_y3 = covar x y3+ !sgn = if cov_x_y >= 0 then 1.0 else (-1.0 :: Double)+ in - cov_x3_y * sgn + cov_x_y3++-- ===========================================================================+-- 内部+-- ===========================================================================++standardize :: LA.Vector Double -> LA.Vector Double+standardize v =+ let !n = fromIntegral (LA.size v) :: Double+ !mu = LA.sumElements v / n+ !c = v - LA.scalar mu+ !s = sqrt (c `LA.dot` c / n)+ !sd = if s > 1e-12 then s else 1.0+ in LA.scale (1 / sd) c++covar :: LA.Vector Double -> LA.Vector Double -> Double+covar a b =+ let !n = fromIntegral (LA.size a) :: Double+ !ma = LA.sumElements a / n+ !mb = LA.sumElements b / n+ !ca = a - LA.scalar ma+ !cb = b - LA.scalar mb+ in ca `LA.dot` cb / n
+ src/Hanalyze/Model/LiNGAM/Parce.hs view
@@ -0,0 +1,309 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE BangPatterns #-}+-- |+-- Module : Hanalyze.Model.LiNGAM.Parce+-- Description : ParceLiNGAM (Tashiro 2014、潜在交絡に頑健な bottom-up + HSIC LiNGAM 拡張)+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- [日本語]: ParceLiNGAM (Tashiro et al. 2014):+-- __潜在交絡 (unobserved confounders) に頑健な__ LiNGAM 拡張。+--+-- ## モデル+--+-- 通常の LiNGAM は @X = B X + e@ で e の各成分独立を要求する。 潜在交絡が+-- ある場合、 観測 X だけ見ると e が独立に見えず DirectLiNGAM は誤った因果+-- 順序を出すことがある。 ParceLiNGAM は:+--+-- > X = B X + Λ · f + e+--+-- ここで f が潜在交絡変数。+--+-- ## アルゴリズム (v0.2、 bottom-up + HSIC、 cdt15/lingam 準拠)+--+-- cdt15/lingam の `lingam/bottom_up_parce_lingam.py` を参照実装とする+-- bottom-up 探索:+--+-- 1. 候補集合 U = {0, .., p-1} を初期化+-- 2. 各候補 j ∈ U について、 残り @U \\ {j}@ の変数で x_j を OLS 回帰した+-- 残差 R を作る。 「x_j が最も下流 (sink)」 ならば+-- @{x_i : i ∈ U \\ {j}}@ と R は独立になるはず+-- 3. 独立度を @hsicAggregate (x_{U \\ {j}}, R)@ で測る (HSIC 総和)。+-- 最小のものを最も下流の候補 j* として選ぶ+-- 4. その HSIC 集約値が threshold @pcAcceptThr@ を下回れば j* を順序末尾に+-- 追加して U から削除。 そうでなければ探索停止+-- 5. 未確定の変数群は __unresolved group__ ('pcUnresolvedGroup') として+-- まとめて返す (潜在交絡で順序が同定不能)+--+-- v0.1 (per-pair OLS + Pairwise LiNGAM) は __削除__ した。 v0.2 は+-- リファレンス実装と同じ「集合 vs 単変量残差」 の依存判定に切替。+--+-- ## 独立性判定の妥協点+--+-- cdt15/lingam では HSIC を gamma 近似で p 値化し Fisher 法で合成する。+-- v0.2 では HSIC __統計量の総和__ を直接スコアとして使い、 閾値で判定する+-- (実装軽量化、 p 値の校正は将来課題)。 相対比較 (どの候補が最も独立か)+-- は機能する。 absolute threshold はサンプル数 / 分散依存なので、 ユーザは+-- @pcAcceptThr@ をデータに合わせて調整する想定。+--+-- ## リファレンス+--+-- Tashiro et al. (2014) "ParceLiNGAM: A causal ordering method robust against+-- latent confounders", Neural Computation 26(1).+-- cdt15/lingam の `lingam/bottom_up_parce_lingam.py`。+--+-- [English]: ParceLiNGAM (Tashiro et al. 2014): a LiNGAM extension+-- __robust to latent confounders (unobserved confounders)__.+--+-- ## Model+--+-- Ordinary LiNGAM, @X = B X + e@, requires each component of e to be+-- independent. When latent confounders are present, e may not appear+-- independent when looking only at the observed X, and DirectLiNGAM can+-- produce an incorrect causal order. ParceLiNGAM instead assumes:+--+-- > X = B X + Λ · f + e+--+-- where f is the latent confounding variable.+--+-- ## Algorithm (v0.2, bottom-up + HSIC, follows cdt15/lingam)+--+-- A bottom-up search whose reference implementation is cdt15/lingam's+-- `lingam/bottom_up_parce_lingam.py`:+--+-- 1. Initialize the candidate set U = {0, .., p-1}+-- 2. For each candidate j ∈ U, build the residual R from OLS-regressing+-- x_j on the remaining variables @U \\ {j}@. If "x_j is the most+-- downstream (sink)", then @{x_i : i ∈ U \\ {j}}@ and R should be+-- independent+-- 3. Measure independence via @hsicAggregate (x_{U \\ {j}}, R)@ (the+-- HSIC sum). Pick the candidate j* with the smallest value as the+-- most downstream+-- 4. If that HSIC aggregate falls below the threshold @pcAcceptThr@,+-- append j* to the end of the order and remove it from U. Otherwise+-- stop the search+-- 5. Any undetermined variables are returned together as an+-- __unresolved group__ ('pcUnresolvedGroup') (order could not be+-- identified due to latent confounders)+--+-- v0.1 (per-pair OLS + Pairwise LiNGAM) was __removed__. v0.2 switched+-- to the same "set vs univariate residual" dependence test as the+-- reference implementation.+--+-- ## Compromise on the independence test+--+-- cdt15/lingam converts HSIC into p-values via a gamma approximation+-- and combines them with Fisher's method. v0.2 uses the+-- __sum of the HSIC statistic__ directly as the score and thresholds it (a+-- lighter-weight implementation; calibrating p-values is future work).+-- Relative comparison (which candidate is most independent) works+-- fine. Since the absolute threshold depends on sample size \/+-- variance, the user is expected to tune @pcAcceptThr@ to their data.+--+-- ## References+--+-- Tashiro et al. (2014) "ParceLiNGAM: A causal ordering method robust+-- against latent confounders", Neural Computation 26(1).+-- cdt15/lingam's `lingam/bottom_up_parce_lingam.py`.+module Hanalyze.Model.LiNGAM.Parce+ ( ParceConfig (..)+ , ParceFit (..)+ , defaultParceConfig+ , fitParceLiNGAM+ , parceDAG+ ) where++import qualified Numeric.LinearAlgebra as LA+import Data.List (foldl', sortBy)+import Data.Ord (comparing)++import qualified Hanalyze.Math.HSIC as HSIC+import qualified Hanalyze.Model.DAG as DAG++-- ===========================================================================+-- 設定 / 結果+-- ===========================================================================++data ParceConfig = ParceConfig+ { pcRelRatio :: !Double+ -- ^ [日本語]: 受理判定の相対比閾値。 best 候補の HSIC 集約値が 2 番目候補の値の+ -- pcRelRatio 倍未満なら sink として受理。 default 0.5+ -- (best が 2nd の半分未満で「明瞭に独立」 と判断)。+ --+ -- 絶対 HSIC の値はサンプル数 / 分散 / median bandwidth に強く依存する+ -- ため、 v0.2 では絶対閾値を捨て __相対比のみ__ で判定する。 集合サイズ |U|+ -- = 2 のときは 2 候補のうち小さい方/大きい方が pcRelRatio 未満+ -- なら受理 (= 自然な「明瞭差」 検出)。+ --+ -- [English]: The relative-ratio threshold for the acceptance+ -- test. Accepted as a sink if the best candidate's HSIC aggregate+ -- is under pcRelRatio times the second candidate's value. Default+ -- 0.5 (judged "clearly independent" if the best is under half the+ -- 2nd).+ --+ -- Because the absolute HSIC value depends strongly on sample+ -- size \/ variance \/ median bandwidth, v0.2 discards the+ -- absolute threshold and judges by __relative ratio only__. When+ -- |U| = 2, it is accepted if the smaller\/larger of the two+ -- candidates is under pcRelRatio (= a natural detector of a+ -- "clear gap").+ , pcPruneThr :: !Double+ -- ^ [日本語]: B 行列 pruning 閾値、 default 0.05。 [English]: The B-matrix pruning threshold, default 0.05.+ } deriving (Show)++defaultParceConfig :: ParceConfig+defaultParceConfig = ParceConfig+ { pcRelRatio = 0.5+ , pcPruneThr = 0.05+ }++data ParceFit = ParceFit+ { pcOrder :: ![Int]+ -- ^ [日本語]: 確定できた causal order (sink → source の順で逆に並んだものを+ -- さらに反転 → source → sink の順)。 unresolved group があるときは+ -- その後ろに連結 (Spec 互換のため任意順で末尾追加)。+ -- [English]: The determined causal order (found in sink → source+ -- order, then reversed → source → sink order). If there is an+ -- unresolved group, it is concatenated after (appended at the end+ -- in arbitrary order for spec compatibility).+ , pcB :: !(LA.Matrix Double)+ -- ^ [日本語]: 構造方程式係数行列。 unresolved 群内の係数は OLS で仮置きされる+ -- (確定的順序が無いので解釈は控えめに)。+ -- [English]: The structural-equation coefficient matrix. The+ -- coefficients within the unresolved group are provisionally set+ -- via OLS (interpret with caution since there is no definite+ -- order).+ , pcAdjacency :: !(LA.Matrix Double)+ , pcUnresolvedGroup :: ![Int]+ -- ^ [日本語]: 潜在交絡で順序が同定不能と判定された変数群 (空ならば全変数確定)。+ -- [English]: The group of variables judged unidentifiable in+ -- order due to latent confounders (empty if all variables are+ -- determined).+ } deriving (Show)++-- ===========================================================================+-- 主実装+-- ===========================================================================++fitParceLiNGAM :: ParceConfig -> LA.Matrix Double -> ParceFit+fitParceLiNGAM cfg x =+ let !p = LA.cols x+ (sinkList, leftover) = bottomUpSearch cfg x [0 .. p - 1]+ -- sinkList は新しく見つけた順に **prepend** しているので、+ -- 自然と「upstream → downstream」 (source → sink) の順に並ぶ。+ -- leftover (確定できなかった残り) を先頭に置く: 長さ 1 なら単なる+ -- source、 長さ ≥ 2 なら **潜在交絡で順序不能** のグループ。+ !fullOrder = leftover ++ sinkList+ !unresolved = if length leftover > 1 then leftover else []+ !bMat = buildBFromOrder p x fullOrder+ !adjMat = adjFromB (pcPruneThr cfg) bMat+ in ParceFit+ { pcOrder = fullOrder+ , pcB = bMat+ , pcAdjacency = adjMat+ , pcUnresolvedGroup = unresolved+ }++-- | [日本語]: DAG 表現を返す。+-- [English]: Returns the DAG representation.+parceDAG :: ParceConfig -> ParceFit -> DAG.DAG+parceDAG cfg fit = DAG.fromBMatrix (pcPruneThr cfg) (pcB fit)++-- ===========================================================================+-- bottom-up 探索+-- ===========================================================================++-- | [日本語]: 候補集合 U から sink を 1 つずつ削り出す。+-- 戻り値: (確定した sink を upstream→downstream の順で並べたリスト、+-- 残り未確定 U)。 ※ prepend で蓄積するため、 最後に見つけたもの+-- (=最も upstream に近い) が先頭、 最初に見つけたもの (=最も downstream)+-- が末尾、 つまり自然な source → sink 順。+-- [English]: Peels off sinks one at a time from the candidate set U.+-- Returns: (the list of determined sinks in upstream→downstream+-- order, the remaining undetermined U). Note: since it accumulates+-- via prepend, the last one found (= closest to upstream) is at the+-- head, and the first one found (= most downstream) is at the tail —+-- i.e. the natural source → sink order.+bottomUpSearch+ :: ParceConfig+ -> LA.Matrix Double+ -> [Int] -- 初期 U (全変数 index)+ -> ([Int], [Int])+bottomUpSearch cfg x = go []+ where+ go !sinks u+ | length u <= 1 = (sinks, u) -- 1 個以下なら確定済とみなす+ | otherwise =+ let scored = sortBy (comparing snd)+ [ (j, scoreSink x u j) | j <- u ]+ (jStar, sB) = head scored+ sNext = snd (scored !! 1)+ accept = sB < pcRelRatio cfg * sNext+ in if accept+ then go (jStar : sinks) (filter (/= jStar) u)+ else (sinks, u) -- 明瞭な sink が無い → halt++-- | [日本語]: 候補 j を sink と仮定したときの「他変数 U\\{j} ⊥ R_j」 の HSIC 集約値。+-- R_j = x_j を x_{U\\{j}} で OLS 回帰した残差。+-- [English]: The HSIC aggregate of "other variables U\\{j} ⊥ R_j"+-- under the assumption that candidate j is the sink. R_j is the+-- residual of x_j OLS-regressed on x_{U\\{j}}.+scoreSink :: LA.Matrix Double -> [Int] -> Int -> Double+scoreSink x u j =+ let others = filter (/= j) u+ xj = LA.flatten (x LA.¿ [j])+ xRest = LA.fromColumns [ LA.flatten (x LA.¿ [k]) | k <- others ]+ r = partialResidual xj xRest+ in HSIC.hsicAggregate xRest r++-- ===========================================================================+-- 内部ヘルパ+-- ===========================================================================++-- | [日本語]: y を Z (n × q 行列) に OLS 回帰した残差。+-- [English]: The residual of OLS-regressing y on Z (an n × q matrix).+partialResidual :: LA.Vector Double -> LA.Matrix Double -> LA.Vector Double+partialResidual y z =+ let xtx = LA.tr z LA.<> z+ xty = LA.tr z LA.#> y+ beta = LA.flatten (LA.linearSolveLS xtx (LA.asColumn xty))+ in y - z LA.#> beta++-- | [日本語]: causal order に従い OLS で B 行列を構築 (DirectLiNGAM と同手順)。+-- [English]: Builds the B matrix via OLS following the causal order+-- (same procedure as DirectLiNGAM).+buildBFromOrder :: Int -> LA.Matrix Double -> [Int] -> LA.Matrix Double+buildBFromOrder p x order =+ let mkRow j =+ let kj = order !! j+ parents = take j order+ in if null parents+ then LA.fromList (replicate p 0)+ else+ let pm = LA.fromColumns+ [ LA.flatten (x LA.¿ [pi_]) | pi_ <- parents ]+ y = LA.flatten (x LA.¿ [kj])+ xtx = LA.tr pm LA.<> pm+ xty = LA.tr pm LA.#> y+ beta = LA.flatten+ (LA.linearSolveLS xtx (LA.asColumn xty))+ updates = zip parents (LA.toList beta)+ coefV = replicate p 0+ filled = foldl' (\acc (i, v) -> set acc i v) coefV updates+ in LA.fromList filled+ bRows = [ mkRow j | j <- [0 .. p - 1] ]+ pos i = case lookup i (zip order [0 ..]) of+ Just k -> k+ Nothing -> 0+ in LA.fromRows [ bRows !! pos i | i <- [0 .. p - 1] ]+ where+ set xs i v = take i xs ++ [v] ++ drop (i + 1) xs++adjFromB :: Double -> LA.Matrix Double -> LA.Matrix Double+adjFromB thr b =+ let p = LA.rows b+ f i j+ | i == j = 0+ | abs (LA.atIndex b (i, j)) > thr = 1+ | otherwise = 0+ in LA.build (p, p) (\i j -> f (round i) (round j) :: Double)
+ src/Hanalyze/Model/LiNGAM/VAR.hs view
@@ -0,0 +1,141 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE BangPatterns #-}+-- |+-- Module : Hanalyze.Model.LiNGAM.VAR+-- Description : VAR-LiNGAM (Hyvärinen et al. 2010) — 時系列データに対する LiNGAM 拡張+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- [日本語]: VAR-LiNGAM (Hyvärinen et al. 2010): 時系列データに対する LiNGAM 拡張。+--+-- ## モデル+--+-- 観測時系列 Y_t ∈ ℝ^K は以下の SVAR (構造 VAR) に従う:+--+-- > Y_t = Σ_{l=1..p} A_l^* · Y_{t-l} + B_0 · Y_t + e_t+--+-- ここで B_0 は同時刻因果 (contemporaneous causal effect、 acyclic + LiNGAM)、+-- e_t は非ガウシアン独立 noise。 通常の reduced-form VAR(p) と関係:+--+-- > Y_t = Σ_l A_l · Y_{t-l} + u_t, u_t = (I - B_0)⁻¹ · e_t+--+-- なので u_t に LiNGAM を適用すれば B_0 が求まり、 A_l^* も A_l と B_0 から+-- 回収できる。+--+-- ## アルゴリズム+--+-- 1. 'Hanalyze.Model.VAR.fitVAR' で reduced-form VAR(p) を fit+-- 2. 残差 u_t (= @varResiduals@) に @fitDirectLiNGAM@ を適用 → B_0 と+-- causal order を取得+-- 3. 構造 lag 行列を A_l^* = (I - B_0) · A_l で復元 (l=1..p)+--+-- ## リファレンス+--+-- Hyvärinen et al. (2010) "Estimation of a Structural Vector Autoregression+-- Model Using Non-Gaussianity", JMLR 11. Python 実装は cdt15/lingam の+-- `lingam/var_lingam.py`。+--+-- [English]: VAR-LiNGAM (Hyvärinen et al. 2010): a LiNGAM extension for+-- time-series data.+--+-- ## Model+--+-- The observed time series Y_t ∈ ℝ^K follows the SVAR (structural VAR)+-- below:+--+-- > Y_t = Σ_{l=1..p} A_l^* · Y_{t-l} + B_0 · Y_t + e_t+--+-- where B_0 is the contemporaneous causal effect (acyclic + LiNGAM),+-- and e_t is non-Gaussian independent noise. Related to the ordinary+-- reduced-form VAR(p):+--+-- > Y_t = Σ_l A_l · Y_{t-l} + u_t, u_t = (I - B_0)⁻¹ · e_t+--+-- so applying LiNGAM to u_t recovers B_0, and A_l^* can also be+-- recovered from A_l and B_0.+--+-- ## Algorithm+--+-- 1. Fit the reduced-form VAR(p) with 'Hanalyze.Model.VAR.fitVAR'+-- 2. Apply @fitDirectLiNGAM@ to the residuals u_t (= @varResiduals@) →+-- obtain B_0 and the causal order+-- 3. Recover the structural lag matrices as A_l^* = (I - B_0) · A_l+-- (l=1..p)+--+-- ## References+--+-- Hyvärinen et al. (2010) "Estimation of a Structural Vector+-- Autoregression Model Using Non-Gaussianity", JMLR 11. The Python+-- implementation is cdt15/lingam's `lingam/var_lingam.py`.+module Hanalyze.Model.LiNGAM.VAR+ ( VARLiNGAMConfig (..)+ , VARLiNGAMFit (..)+ , defaultVARLiNGAMConfig+ , fitVARLiNGAM+ , vlDAG+ ) where++import qualified Numeric.LinearAlgebra as LA++import qualified Hanalyze.Model.VAR as V+import qualified Hanalyze.Model.LiNGAM.Direct as DL+import qualified Hanalyze.Model.DAG as DAG++-- ===========================================================================+-- 設定 / 結果+-- ===========================================================================++data VARLiNGAMConfig = VARLiNGAMConfig+ { vlcLagOrder :: !Int+ -- ^ [日本語]: VAR の lag 数 p (≥ 1)。 [English]: The VAR lag count p (≥ 1).+ , vlcDirectCfg :: !DL.DirectLiNGAMConfig+ } deriving (Show)++defaultVARLiNGAMConfig :: VARLiNGAMConfig+defaultVARLiNGAMConfig = VARLiNGAMConfig+ { vlcLagOrder = 1+ , vlcDirectCfg = DL.defaultDirectLiNGAMConfig+ }++data VARLiNGAMFit = VARLiNGAMFit+ { vlVARFit :: !V.VARFit+ -- ^ [日本語]: reduced-form VAR(p) fit 結果。 [English]: The reduced-form VAR(p) fit result.+ , vlContempLiNGAM :: !DL.DirectLiNGAMFit+ -- ^ [日本語]: 残差 u_t に対する DirectLiNGAM 結果 (= 同時刻因果 B_0)。 [English]: The DirectLiNGAM result on the residuals u_t (= the contemporaneous causal effect B_0).+ , vlB0 :: !(LA.Matrix Double)+ -- ^ [日本語]: 同時刻因果係数 (K × K)、 = vlContempLiNGAM の dlB。 [English]: The contemporaneous causal coefficients (K × K), = vlContempLiNGAM's dlB.+ , vlStructuralLags :: ![LA.Matrix Double]+ -- ^ [日本語]: 構造 lag 行列 A_l^* = (I - B_0) · A_l (length = p)。 [English]: The structural lag matrices A_l^* = (I - B_0) · A_l (length = p).+ , vlContempOrder :: ![Int]+ } deriving (Show)++-- ===========================================================================+-- 主実装+-- ===========================================================================++fitVARLiNGAM :: VARLiNGAMConfig -> LA.Matrix Double -> VARLiNGAMFit+fitVARLiNGAM cfg y =+ let !varFit = V.fitVAR (vlcLagOrder cfg) y+ !resid = V.varResiduals varFit+ !lgFit = DL.fitDirectLiNGAM (vlcDirectCfg cfg) resid+ !b0 = DL.dlB lgFit+ !k = V.varK varFit+ !iMinusB0 = LA.ident k - b0+ !structLags =+ [ iMinusB0 LA.<> al | al <- V.varCoefs varFit ]+ in VARLiNGAMFit+ { vlVARFit = varFit+ , vlContempLiNGAM = lgFit+ , vlB0 = b0+ , vlStructuralLags = structLags+ , vlContempOrder = DL.dlOrder lgFit+ }++-- | [日本語]: 同時刻因果 (B_0) の DAG 表現を返す。 lag 部分は含まない (時間方向は別の+-- 表現が必要、 v0.1 では同時刻のみ DAG 化)。+-- [English]: Returns the DAG representation of the contemporaneous+-- causal effect (B_0). Does not include the lag part (the time+-- direction needs a separate representation; v0.1 only DAG-izes the+-- contemporaneous part).+vlDAG :: VARLiNGAMConfig -> VARLiNGAMFit -> DAG.DAG+vlDAG cfg fit = DAG.fromBMatrix (DL.dlcPruneThr (vlcDirectCfg cfg)) (vlB0 fit)
+ src/Hanalyze/Model/MDS.hs view
@@ -0,0 +1,142 @@+{-# LANGUAGE OverloadedStrings #-}+-- |+-- Module : Hanalyze.Model.MDS+-- Description : MDS (多次元尺度構成法) の高レベルモデル型+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- [日本語]: MDS の高レベルモデル型。+--+-- 低レベルの行列カーネル (@mdsClassical@ / 'mdsSammon' / @euclideanDist@) は+-- 'Hanalyze.Stat.MDS' に置き、 ここは @df |-> mds cfg cols@ で使う+-- __モデル型__ 'MDSResult' (= 'Hanalyze.Model.PCA.PCAResult' と同格) と+-- その設定 'MDSConfig' を提供する。+--+-- MDS (多次元尺度構成法) = サンプル間の __距離 (非類似度) を保ったまま__ 高次元+-- データを 2D へ配置する可視化・次元圧縮。 'MDSClassical' (Torgerson・ユークリッド+-- 距離なら PCA と等価) と 'MDSSammon' (小距離重視の非線形版) を選べる。 結果は+-- 埋め込み (MDS1/MDS2) に加え __元データ (群色付け用の列を含む)__ を保持し、+-- plot 側で @toPlot m@ (単色散布) / @toPlot (mdsView m <> mdsGroupBy \"g\")@ (群色) に使う。+--+-- [English]: A high-level MDS model type.+--+-- The low-level matrix kernels (@mdsClassical@ \/ 'mdsSammon' \/+-- @euclideanDist@) live in 'Hanalyze.Stat.MDS'; this module provides+-- the __model type__ 'MDSResult' (on par with+-- 'Hanalyze.Model.PCA.PCAResult') used with @df |-> mds cfg cols@,+-- along with its configuration 'MDSConfig'.+--+-- MDS (multidimensional scaling) is a visualization \/ dimensionality+-- reduction technique that places high-dimensional data in 2D+-- __while preserving the distances (dissimilarities) between samples__.+-- You can+-- choose between 'MDSClassical' (Torgerson; equivalent to PCA for+-- Euclidean distance) and 'MDSSammon' (a nonlinear variant that+-- emphasizes small distances). The result holds the embedding (MDS1/MDS2)+-- plus the __source data (including columns for group coloring)__, used on+-- the plot side via @toPlot m@ (single-color scatter) \/+-- @toPlot (mdsView m <> mdsGroupBy \"g\")@ (group coloring).+module Hanalyze.Model.MDS+ ( -- * 手法と設定+ MDSMethod (..)+ , MDSConfig (..)+ , defaultMDS+ -- ** 再 export (Sammon パラメータ)+ , SammonConfig (..)+ , defaultSammonConfig+ -- * モデル型+ , MDSResult (..)+ , runMDS+ ) where++import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Vector as V+import qualified Numeric.LinearAlgebra as LA+import qualified DataFrame.Internal.DataFrame as DX++import Hanalyze.DataIO.Convert (getDoubleVec)+import qualified Hanalyze.Stat.MDS as S+import Hanalyze.Stat.MDS (SammonConfig (..), defaultSammonConfig)++-- ===========================================================================+-- 手法と設定+-- ===========================================================================++-- | [日本語]: MDS の手法選択。 'MDSClassical' = 古典 MDS (Torgerson・固有分解)、+-- 'MDSSammon' = Sammon 写像 (小距離重視の非線形・勾配降下)。+-- [English]: The MDS method choice. 'MDSClassical' = classical MDS+-- (Torgerson; eigendecomposition); 'MDSSammon' = Sammon mapping (a+-- nonlinear variant emphasizing small distances; gradient descent).+data MDSMethod = MDSClassical | MDSSammon+ deriving (Show, Eq)++-- | [日本語]: MDS の設定。 手法 ('mdsMethod') と、 'MDSSammon' 選択時に使う Sammon+-- パラメータ ('mdsSammon') を持つ (他の config 同様レコード型・裸の直和を+-- spec 引数にしない)。 k=2 固定・距離はユークリッドのみ (現状実装どおり)。+-- [English]: The MDS configuration. Holds the method ('mdsMethod') and the+-- Sammon parameters ('mdsSammon') used when 'MDSSammon' is chosen (as with+-- other configs, this is a record type rather than a bare sum type as the+-- spec argument). k=2 is fixed, and the distance is Euclidean only (as+-- currently implemented).+data MDSConfig = MDSConfig+ { mdsMethod :: !MDSMethod -- ^ [日本語]: 古典 / Sammon。 [English]: Classical \/ Sammon.+ , mdsSammon :: !SammonConfig -- ^ [日本語]: 'MDSSammon' 選択時の勾配降下パラメータ。 [English]: The gradient-descent parameters used when 'MDSSammon' is chosen.+ } deriving (Show)++-- | [日本語]: 既定設定: 古典 MDS・Sammon パラメータは既定。+-- [English]: The default configuration: classical MDS, default Sammon+-- parameters.+defaultMDS :: MDSConfig+defaultMDS = MDSConfig MDSClassical defaultSammonConfig++-- ===========================================================================+-- モデル型+-- ===========================================================================++-- | [日本語]: 学習済 MDS。 2D 埋め込み (MDS1/MDS2) に加え、 __元データ ('mdsSourceFrame')__ を+-- 保持して plot 側の群色付け (@mdsGroupBy@) に使う。 'Hanalyze.Model.PCA.PCAResult'+-- と同格のモデル型 (df 型ではない)。+-- [English]: A fitted MDS. Holds the 2D embedding (MDS1/MDS2) plus the+-- __source data ('mdsSourceFrame')__, used for group coloring+-- (@mdsGroupBy@) on the plot side. A model type on par with+-- 'Hanalyze.Model.PCA.PCAResult' (not a df type).+data MDSResult = MDSResult+ { mdsMethodUsed :: !MDSMethod -- ^ [日本語]: 使った手法。 [English]: The method used.+ , mdsEmbedding :: !(LA.Matrix Double) -- ^ [日本語]: 埋め込み (n × 2)。 [English]: The embedding (n × 2).+ , mdsFeatures :: ![Text] -- ^ [日本語]: 入力に使った特徴列名。 [English]: The feature column names used as input.+ , mdsSourceFrame :: !DX.DataFrame -- ^ [日本語]: 元データ (群色付け用に保持)。 [English]: The source data (kept for group coloring).+ }++-- | [日本語]: @runMDS cfg frame cols@ — frame の特徴列 @cols@ を行列化し、 ユークリッド+-- 距離 → 古典 / Sammon MDS で 2D 埋め込みを得る。 列が無い / 長さ不揃いなら 'Left'。+-- [English]: @runMDS cfg frame cols@ — matrixizes frame's feature columns+-- @cols@, and obtains a 2D embedding via Euclidean distance → classical \/+-- Sammon MDS. Returns 'Left' if columns are missing \/ of mismatched+-- length.+runMDS :: MDSConfig -> DX.DataFrame -> [Text] -> Either String MDSResult+runMDS _ _ [] = Left "MDS: 特徴列が空です (1 列以上必要)"+runMDS cfg frame cols = do+ colVecs <- mapM getCol cols+ let lens = map length colVecs+ if not (allEq lens)+ then Left ("MDS: 特徴列の長さが不揃いです: " <> show lens)+ else do+ let n = head lens+ xMat = LA.fromLists [ [ v !! i | v <- colVecs ] | i <- [0 .. n - 1] ]+ d = S.euclideanDist xMat+ emb = case mdsMethod cfg of+ MDSClassical -> S.mdsClassical d 2+ MDSSammon -> S.mdsSammon (mdsSammon cfg) d 2+ Right MDSResult+ { mdsMethodUsed = mdsMethod cfg+ , mdsEmbedding = emb+ , mdsFeatures = cols+ , mdsSourceFrame = frame+ }+ where+ getCol c = case V.toList <$> getDoubleVec c frame of+ Just vs -> Right vs+ Nothing -> Left ("MDS: 数値列が見つかりません: " <> T.unpack c)+ allEq [] = True+ allEq (x:xs) = all (== x) xs
+ src/Hanalyze/Model/MultiGP.hs view
@@ -0,0 +1,325 @@+{-# LANGUAGE OverloadedStrings #-}+-- |+-- Module : Hanalyze.Model.MultiGP+-- Description : Multi-output Gaussian processes (共有 HP / per-output 独立 HP の 2 戦略)+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- Multi-output Gaussian processes.+--+-- Two strategies are offered; pick by how outputs should share+-- hyperparameters:+--+-- * __Shared-HP (default)__ — @fitMultiGP@ / @fitMultiGPMV@.+-- RBF only. A /single/ HP optimisation maximises the pooled marginal+-- likelihood @Σ_q log p(y_q | θ)@, and the resulting Cholesky factor+-- of @Ky@ is reused for every output's posterior solve. Mirrors+-- scikit-learn's @GaussianProcessRegressor.fit(X, Y::(n,q))@. About+-- @q@-fold faster than the per-output variant when @q > 1@.+--+-- * __Per-output independent HPs__ — @fitMultiGPIndep@ /+-- @fitMultiGPMVIndep@. Supports any 'Kernel' kind. Each output+-- runs its own LBFGS HP fit, so per-task flexibility is preserved+-- at @q × O(LBFGS)@ cost.+--+-- Both treat outputs as independent likelihoods (@B = I@ in the+-- Intrinsic Coregionalization Model). Co-kriging / LMC kernels with+-- learned cross-output correlations are not implemented.+module Hanalyze.Model.MultiGP+ ( MultiGPModel (..)+ -- * Default (shared-HP, RBF only)+ , MultiGPResult (..)+ , mgpStd+ , fitMultiGP+ , predictMultiGP+ , MultiGPResultMV (..)+ , fitMultiGPMV+ -- * Per-output independent HPs (any kernel)+ , fitMultiGPIndep+ , fitMultiGPMVIndep+ ) where++import qualified Numeric.LinearAlgebra as LA+import Hanalyze.Model.GP (Kernel (..), GPModel (..), GPParams (..),+ GPResult (..),+ fitGP, optimizeGP, initParamsFromData, initParamsFromDataMV,+ GPResultMV (..), fitGPMV, optimizeGPMVCached)+import qualified Hanalyze.Stat.KernelDist as KD+import qualified Hanalyze.Stat.Cholesky as Chol+import qualified Hanalyze.Optim.LBFGS as LBFGS+import qualified Hanalyze.Optim.Common as OC+import System.IO.Unsafe (unsafePerformIO)++-- | Multi-output GP model with a per-output set of hyperparameters.+-- All outputs share the same kernel /type/ for simplicity; their+-- length-scales etc. are still optimized independently.+data MultiGPModel = MultiGPModel+ { mgpKernel :: Kernel+ , mgpParams :: [GPParams] -- ^ Hyperparameters per output.+ } deriving (Show)++-- | Per-output GP fit results.+data MultiGPResult = MultiGPResult+ { mgpMean :: [[Double]] -- ^ Predictive means, one list per output (length @q@).+ , mgpLower :: [[Double]] -- ^ 95 % lower band (@mean − 2σ@) per output.+ , mgpUpper :: [[Double]] -- ^ 95 % upper band (@mean + 2σ@) per output.+ , mgpModels :: [GPModel] -- ^ Underlying per-output 'GPModel's.+ } deriving (Show)++-- | Recover the per-output predictive standard deviation @σ@ from the+-- @mean@ / @upper@ bands.+mgpStd :: MultiGPResult -> [[Double]]+mgpStd r = zipWith (zipWith (\m u -> (u - m) / 2)) (mgpMean r) (mgpUpper r)++-- | Fit a multi-output GP with shared RBF hyperparameters (default API).+--+-- This is the 1D-input wrapper around 'fitMultiGPMV'. A single HP set+-- is learned by maximising the pooled marginal likelihood over all+-- @q@ outputs, then one Cholesky factor of @Ky = K + σ_n² I@ is+-- reused for each output's posterior solve.+--+-- For per-output independent HPs (any kernel kind), use+-- 'fitMultiGPIndep'.+fitMultiGP :: [Double] -- ^ Training inputs (1D).+ -> [[Double]] -- ^ Per-output training values (length @q@).+ -> [Double] -- ^ Test inputs.+ -> MultiGPResult+fitMultiGP trainX trainYs testX =+ let xMat = LA.asColumn (LA.fromList trainX)+ tMat = LA.asColumn (LA.fromList testX)+ yVecs = map LA.fromList trainYs+ r = fitMultiGPMV xMat yVecs tMat+ in MultiGPResult+ { mgpMean = map LA.toList (mgpmvMean r)+ , mgpLower = map LA.toList (mgpmvLower r)+ , mgpUpper = map LA.toList (mgpmvUpper r)+ , mgpModels = mgpmvModels r+ }++-- | Fit a multi-output GP with per-output independent hyperparameters.+--+-- Each output runs its own 'optimizeGP' LBFGS loop, then is predicted+-- at @testX@. Supports any 'Kernel' kind. Use this when outputs need+-- distinct length-scales / noise levels.+--+-- For sklearn-style shared-HP behaviour (RBF, single HP optimisation,+-- much faster when @q > 1@), use 'fitMultiGP'.+fitMultiGPIndep :: Kernel -- ^ Kernel kind shared by every output.+ -> [Double] -- ^ Training inputs (1D).+ -> [[Double]] -- ^ Per-output training values (length @q@).+ -> [Double] -- ^ Test inputs.+ -> MultiGPResult+fitMultiGPIndep kern trainX trainYs testX =+ let perOutput :: [Double] -> (GPModel, GPResult)+ perOutput trainY =+ let p0 = initParamsFromData trainX trainY+ pOpt = optimizeGP kern trainX trainY p0+ mdl = GPModel kern pOpt+ res = fitGP mdl trainX trainY testX+ in (mdl, res)+ pairs = map perOutput trainYs+ models = map fst pairs+ results = map snd pairs+ in MultiGPResult+ { mgpMean = map gpMean results+ , mgpLower = map gpLower results+ , mgpUpper = map gpUpper results+ , mgpModels = models+ }++-- | Re-predict an existing 'MultiGPModel' at new test inputs (no+-- re-fitting).+predictMultiGP :: MultiGPModel+ -> [Double] -- ^ Training inputs.+ -> [[Double]] -- ^ Per-output training values.+ -> [Double] -- ^ Test inputs.+ -> MultiGPResult+predictMultiGP mgp trainX trainYs testX =+ let kern = mgpKernel mgp+ models = zipWith (\p _ -> GPModel kern p) (mgpParams mgp) trainYs+ results = zipWith3 (\m _ ty -> fitGP m trainX ty testX)+ models trainYs trainYs+ in MultiGPResult+ { mgpMean = map gpMean results+ , mgpLower = map gpLower results+ , mgpUpper = map gpUpper results+ , mgpModels = models+ }++-- ---------------------------------------------------------------------------+-- Multi-input (multivariate X) API+-- ---------------------------------------------------------------------------++-- | Multi-input multi-output GP fit result. Per-output mean / band+-- vectors (length @m@), with the optimized 'GPModel' that produced them.+data MultiGPResultMV = MultiGPResultMV+ { mgpmvMean :: [LA.Vector Double]+ , mgpmvLower :: [LA.Vector Double]+ , mgpmvUpper :: [LA.Vector Double]+ , mgpmvModels :: [GPModel]+ } deriving (Show)++-- | Multi-output GP fit with multivariate input and /shared/ RBF+-- hyperparameters (default API).+--+-- Mirrors @sklearn.gaussian_process.GaussianProcessRegressor@'s+-- @fit(X, Y::(n,q))@ behaviour: one HP optimisation against the+-- pooled marginal likelihood @Σ_q log p(y_q | θ)@, then a single+-- Cholesky factor of @Ky = K + σ_n² I@ reused for every output's+-- posterior solve. Roughly @q@-fold faster than 'fitMultiGPMVIndep'+-- when @q > 1@.+--+-- RBF only. For other kernels (Matérn 5/2, periodic) or per-output+-- length-scales, use 'fitMultiGPMVIndep'.+fitMultiGPMV+ :: LA.Matrix Double -- ^ Training @X@ (@n × p@).+ -> [LA.Vector Double] -- ^ Per-output training values (length @q@).+ -> LA.Matrix Double -- ^ Test inputs (@m × p@).+ -> MultiGPResultMV+fitMultiGPMV trainX trainYs testX =+ let q = length trainYs+ yMat = LA.fromColumns trainYs -- n × q+ sharedD = KD.pairwiseSqDist trainX+ -- Use the first output as the reference for HP initial values+ -- (any output works; the result of the joint optimisation is+ -- the same).+ p0 = case trainYs of+ (y0 : _) -> initParamsFromDataMV trainX y0+ [] -> error "fitMultiGPMV: no outputs"+ pOpt = optimizeRBFAnalyticMulti sharedD trainX yMat p0+ mdl = GPModel RBF pOpt+ results = [ fitGPMV mdl trainX yi testX | yi <- trainYs ]+ in MultiGPResultMV+ { mgpmvMean = map gpmvMean results+ , mgpmvLower = map gpmvLower results+ , mgpmvUpper = map gpmvUpper results+ , mgpmvModels = replicate q mdl -- shared model+ }++-- | Like 'Hanalyze.Model.GP.optimizeRBFAnalytic' but the marginal likelihood is+-- the /sum/ over @q@ outputs sharing one kernel — single HP fit.+--+-- Internally factor Ky once per LBFGS step, solve @α = Ky⁻¹ Y@ as one+-- @n × q@ RHS, and assemble the gradient via+-- @∇L = ½ tr((α αᵀ − q Ky⁻¹) ∂Ky/∂θ)@.+optimizeRBFAnalyticMulti+ :: LA.Matrix Double -- ^ Pre-computed @D = pairwiseSqDist trainX@.+ -> LA.Matrix Double -- ^ Training @X@ (used only for shape; actual+ -- computations go through @D@).+ -> LA.Matrix Double -- ^ @Y@ (@n × q@), one column per output.+ -> GPParams -- ^ Initial params.+ -> GPParams+optimizeRBFAnalyticMulti d2 trainX yMat p0 =+ let n = LA.rows trainX+ q = LA.cols yMat+ qD = fromIntegral q :: Double+ cfg = optimizerConfig+ u0v = LA.fromList+ [ log (gpLengthScale p0)+ , log (gpSignalVar p0)+ , log (gpNoiseVar p0) ]++ buildK uv =+ let !ll = exp (uv `LA.atIndex` 0)+ !sf2 = exp (uv `LA.atIndex` 1)+ !sn2 = exp (uv `LA.atIndex` 2)+ !inv2L2 = 1 / (2 * ll * ll)+ !kMat = LA.cmap (\s -> sf2 * exp (- s * inv2L2)) d2+ !kyM = kMat + LA.scale sn2 (LA.ident n)+ in (ll, sf2, sn2, kMat, kyM)++ objV uv =+ let (_, _, _, _, kyM) = buildK uv+ in case Chol.cholFactor kyM of+ Nothing -> -1e30+ Just r ->+ let logDet = 2 * sum (map log (LA.toList (LA.takeDiag r)))+ alpha = Chol.cholSolveWithFactor r yMat -- n × q+ -- Σ_q y_qᵀ α_q = trace(Yᵀ α) = elementwise sum (Y ⊙ α)+ dataFit = LA.sumElements (yMat * alpha)+ in -0.5 * dataFit - 0.5 * qD * logDet+ - fromIntegral n * qD / 2 * log (2 * pi)++ gradV uv =+ let (ll, _sf2, sn2, kMat, kyM) = buildK uv+ in case Chol.cholFactor kyM of+ Nothing -> LA.fromList [0, 0, 0]+ Just r ->+ let alpha = Chol.cholSolveWithFactor r yMat -- n × q+ kyInv = Chol.cholSolveWithFactor r (LA.ident n)+ -- Σ_q (α_qᵀ V α_q) = elementwise sum of (α ⊙ (V α))+ sumAVA v =+ let vAlpha = v LA.<> alpha -- n × q+ in LA.sumElements (alpha * vAlpha)+ -- ∂Ky/∂(log ℓ)+ !invL2 = 1 / (ll * ll)+ !vL = LA.scale invL2 (kMat * d2)+ !aVa_L = sumAVA vL+ !tr_L = LA.sumElements (kyInv * vL)+ !gLogL = 0.5 * (aVa_L - qD * tr_L)+ -- ∂Ky/∂(log σ_f²) = K+ !aVa_K = sumAVA kMat+ !tr_K = LA.sumElements (kyInv * kMat)+ !gLogSf = 0.5 * (aVa_K - qD * tr_K)+ -- ∂Ky/∂(log σ_n²) = σ_n² I+ !aVa_I = LA.sumElements (alpha * alpha) -- ‖α‖²_F+ !tr_I = LA.sumElements (LA.takeDiag kyInv)+ !gLogSn = 0.5 * sn2 * (aVa_I - qD * tr_I)+ in LA.fromList [gLogL, gLogSf, gLogSn]++ result = unsafePerformIO $ LBFGS.runLBFGSWithV cfg objV gradV u0v+ uOpt = OC.orBest result+ in p0+ { gpLengthScale = exp (uOpt !! 0)+ , gpSignalVar = exp (uOpt !! 1)+ , gpNoiseVar = exp (uOpt !! 2)+ }+ where+ optimizerConfig =+ LBFGS.defaultLBFGSConfig+ { LBFGS.lbDir = OC.Maximize+ , LBFGS.lbStop = OC.defaultStopCriteria+ { OC.stMaxIter = 200, OC.stTolFun = 1e-8 }+ }++-- | Multi-output GP fit with multivariate input and /independent/+-- per-output hyperparameters.+--+-- Each output column runs its own LBFGS HP optimisation via+-- @optimizeGPMVCached@. Supports any 'Kernel' kind (RBF / Matérn 5/2+-- / periodic). Costs @q × O(LBFGS)@; use 'fitMultiGPMV' (shared HP)+-- for a roughly @q@-fold speed-up when outputs are homogeneous.+--+-- The pairwise distance matrix @D = pairwiseSqDist X@ is shared+-- across the @q@ per-output optimisations to save @(q − 1) × O(n²)@+-- work.+fitMultiGPMVIndep+ :: Kernel+ -> LA.Matrix Double -- ^ Training @X@ (@n × p@).+ -> [LA.Vector Double] -- ^ Per-output training values (length @q@).+ -> LA.Matrix Double -- ^ Test inputs (@m × p@).+ -> MultiGPResultMV+fitMultiGPMVIndep kern trainX trainYs testX =+ let -- @D = pairwiseSqDist trainX@ is shared across all q outputs+ -- (trainX is the same input matrix), so we compute it once and+ -- pass it into 'optimizeGPMVCached'. Each output's HP loop then+ -- re-uses the same @D@ instead of recomputing it inside its own+ -- per-output cache. Saves @(q − 1) × O(n²)@ work for kernel that+ -- uses the isotropic length scale.+ sharedD = KD.pairwiseSqDist trainX+ perOutput :: LA.Vector Double -> (GPModel, GPResultMV)+ perOutput trainY =+ let p0 = initParamsFromDataMV trainX trainY+ pOpt = optimizeGPMVCached kern (Just sharedD) trainX trainY p0+ mdl = GPModel kern pOpt+ res = fitGPMV mdl trainX trainY testX+ in (mdl, res)+ pairs = map perOutput trainYs+ models = map fst pairs+ results = map snd pairs+ in MultiGPResultMV+ { mgpmvMean = map gpmvMean results+ , mgpmvLower = map gpmvLower results+ , mgpmvUpper = map gpmvUpper results+ , mgpmvModels = models+ }
+ src/Hanalyze/Model/MultiLM.hs view
@@ -0,0 +1,82 @@+{-# LANGUAGE OverloadedStrings #-}+-- |+-- Module : Hanalyze.Model.MultiLM+-- Description : Multivariate (multi-output) linear regression — 列別 OLS + 残差共分散推定+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- Multivariate (multi-output) linear regression.+--+-- @Y = XB + E@ with @Y@ of shape @n × q@ (@q@ outputs), @X@ of shape+-- @n × p@, @B@ of shape @p × q@ and @E@ of shape @n × q@.+--+-- Solves each column independently by OLS (column-wise OLS) and+-- additionally estimates the residual covariance matrix @Σ@, which is+-- used for joint multi-output predictive intervals.+--+-- The API matches 'Hanalyze.Model.LM', so @fitLM@ can be called directly; this+-- module merely exposes the additional multi-output information+-- (@Σ@, correlation matrix).+module Hanalyze.Model.MultiLM+ ( MultiFit (..)+ , fitMultiLM+ , predictMultiLM+ , residualCovariance+ , residualCorrelation+ ) where++import qualified Numeric.LinearAlgebra as LA+import Hanalyze.Model.Core (FitResult (..))+import qualified Hanalyze.Model.LM as LM++-- | Augmented result for multi-output linear regression.+data MultiFit = MultiFit+ { mfFit :: FitResult -- ^ Underlying matrix-based fit.+ , mfResidCov :: LA.Matrix Double -- ^ Residual covariance @Σ@ (@q × q@).+ , mfResidCor :: LA.Matrix Double -- ^ Residual correlation matrix (@q × q@).+ , mfNumOutputs :: Int -- ^ Number of responses @q@.+ , mfNumPredict :: Int -- ^ Number of predictors @p@.+ , mfNumSamples :: Int -- ^ Number of observations @n@.+ } deriving (Show)++-- | Multi-output linear regression: @Y = XB + E@.+-- Delegates to 'LM.fitLM' and additionally returns the residual+-- covariance.+fitMultiLM :: LA.Matrix Double -- ^ Design matrix @X@ (@n × p@).+ -> LA.Matrix Double -- ^ Response @Y@ (@n × q@).+ -> MultiFit+fitMultiLM x y =+ let fit = LM.fitLM x y+ res = residuals fit+ n = LA.rows y+ q = LA.cols y+ p = LA.cols x+ df = max 1 (n - p) -- 自由度補正+ -- Σ = (1/(n-p)) * Eᵀ E+ sigma = LA.scale (1 / fromIntegral df)+ (LA.tr res LA.<> res)+ -- 相関行列: D⁻¹ Σ D⁻¹ where D = diag(sqrt(diag(Σ)))+ diagS = [ sqrt (sigma `LA.atIndex` (i, i))+ | i <- [0 .. q - 1] ]+ corr = LA.fromLists+ [ [ if di == 0 || dj == 0 then 0+ else (sigma `LA.atIndex` (i, j)) / (di * dj)+ | j <- [0 .. q - 1]+ , let dj = diagS !! j ]+ | i <- [0 .. q - 1]+ , let di = diagS !! i ]+ in MultiFit fit sigma corr q p n++-- | Predict @Ŷ@ (@m × q@) for new inputs @X_new@ (@m × p@). A thin+-- wrapper around 'LM.predictLM'.+predictMultiLM :: MultiFit -> LA.Matrix Double -> LA.Matrix Double+predictMultiLM mf xNew =+ LM.predictLM (coefficients (mfFit mf)) xNew++-- | Residual covariance matrix (alias for 'mfResidCov').+residualCovariance :: MultiFit -> LA.Matrix Double+residualCovariance = mfResidCov++-- | Residual correlation matrix.+residualCorrelation :: MultiFit -> LA.Matrix Double+residualCorrelation = mfResidCor
+ src/Hanalyze/Model/MultiOutput.hs view
@@ -0,0 +1,85 @@+-- |+-- Module : Hanalyze.Model.MultiOutput+-- Description : Common foundation for multi-output regression (単出力 ↔ 多出力変換 + 評価指標)+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- Common foundation for multi-output regression.+--+-- Design policy:+--+-- * Each model's /primary/ API takes the response @Y@ as+-- @LA.Matrix Double@ (@n × q@) and returns a matrix; the @q = 1@ case+-- is a specialization.+-- * The single-output API (@V.Vector Double@) is a thin wrapper that+-- promotes the response to a one-column matrix via 'asMultiY' /+-- 'fromMultiY' and reuses the multi-output implementation.+-- * Per-output evaluation metrics (R² etc.) are collected here.+module Hanalyze.Model.MultiOutput+ ( -- * 単出力 ↔ 多出力 変換+ asMultiY+ , fromMultiY+ , asMultiYV+ -- * Multi-output evaluation metrics+ , rmseMulti+ , r2Multi+ , mseMulti+ ) where++import qualified Data.Vector as V+import qualified Numeric.LinearAlgebra as LA++-- ---------------------------------------------------------------------------+-- 変換+-- ---------------------------------------------------------------------------++-- | Promote a 1D 'V.Vector' to an @n × 1@ matrix.+--+-- >>> import qualified Data.Vector as V+-- >>> LA.rows (asMultiY (V.fromList [1.0, 2.0, 3.0]))+-- 3+-- >>> LA.cols (asMultiY (V.fromList [1.0, 2.0, 3.0]))+-- 1+asMultiY :: V.Vector Double -> LA.Matrix Double+asMultiY = LA.asColumn . LA.fromList . V.toList++-- | Promote an hmatrix 'LA.Vector' to an @n × 1@ matrix.+asMultiYV :: LA.Vector Double -> LA.Matrix Double+asMultiYV = LA.asColumn++-- | Convert an @n × 1@ matrix back to a 1D vector. When @q ≠ 1@, returns+-- the first column.+fromMultiY :: LA.Matrix Double -> V.Vector Double+fromMultiY m+ | LA.cols m == 0 = V.empty+ | otherwise = V.fromList (LA.toList (LA.flatten (m LA.¿ [0])))++-- ---------------------------------------------------------------------------+-- 評価指標+-- ---------------------------------------------------------------------------++-- | Whole-matrix MSE: sum-of-squares divided by @n × q@.+mseMulti :: LA.Matrix Double -> LA.Matrix Double -> Double+mseMulti ys yhat =+ let n = LA.rows ys+ q = LA.cols ys+ r = ys - yhat+ in LA.sumElements (r * r) / fromIntegral (n * q)++-- | Whole-matrix RMSE.+rmseMulti :: LA.Matrix Double -> LA.Matrix Double -> Double+rmseMulti ys yhat = sqrt (mseMulti ys yhat)++-- | Per-column R² (vector of length @q@).+r2Multi :: LA.Matrix Double -> LA.Matrix Double -> V.Vector Double+r2Multi ys yhat =+ let n = LA.rows ys+ q = LA.cols ys+ colR2 j =+ let yc = LA.toList (LA.flatten (ys LA.¿ [j]))+ yhc = LA.toList (LA.flatten (yhat LA.¿ [j]))+ mu = sum yc / fromIntegral n+ sst = sum [(y - mu)^(2::Int) | y <- yc]+ sse = sum [(y - p)^(2::Int) | (y, p) <- zip yc yhc]+ in if sst == 0 then 0 else 1 - sse / sst+ in V.fromList [ colR2 j | j <- [0 .. q - 1] ]
+ src/Hanalyze/Model/Multivariate.hs view
@@ -0,0 +1,186 @@+{-# LANGUAGE OverloadedStrings #-}+-- |+-- Module : Hanalyze.Model.Multivariate+-- Description : Specialized multivariate regression — Reduced-Rank Regression / PLS / CCA+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- Specialized multivariate regression: Reduced-Rank Regression, PLS,+-- and CCA.+--+-- These all express the relationship between a multi-response @Y@+-- (@n × q@) and multi-predictor @X@ (@n × p@) via a low-rank structure.+--+-- * 'reducedRankRegression' — @B = U_r V_rᵀ@ (rank-@r@ constraint).+-- * 'pls' — extracts directions of maximum+-- @X@-@Y@ covariance one at a time.+-- * 'cca' — canonical pairs maximizing @X@-@Y@+-- correlation.+module Hanalyze.Model.Multivariate+ ( -- * Reduced Rank Regression+ RRRFit (..)+ , reducedRankRegression+ , predictRRR+ -- * Partial Least Squares+ , PLSFit (..)+ , pls+ , predictPLS+ -- * Canonical Correlation Analysis+ , CCAFit (..)+ , cca+ ) where++import qualified Numeric.LinearAlgebra as LA++-- ---------------------------------------------------------------------------+-- Reduced Rank Regression+-- ---------------------------------------------------------------------------++-- | Reduced-Rank Regression result. The coefficient matrix @B@ is+-- constrained to rank @r@.+data RRRFit = RRRFit+ { rrrBeta :: LA.Matrix Double -- ^ @B@ of shape @p × q@ (rank @≤ r@).+ , rrrU :: LA.Matrix Double -- ^ Left factor (@p × r@).+ , rrrV :: LA.Matrix Double -- ^ Right factor (@q × r@).+ , rrrRank :: Int -- ^ Effective rank.+ } deriving (Show)++-- | Reduced-Rank Regression: @B = U Vᵀ@ with rank @r@.+--+-- The OLS estimate @B̂@ is SVD-truncated to its top @r@ singular values:+-- @B̂_RRR = U_r Σ_r V_rᵀ@.+reducedRankRegression :: Int -- ^ Target rank @r@.+ -> LA.Matrix Double -- ^ Design matrix @X@ (@n × p@).+ -> LA.Matrix Double -- ^ Response @Y@ (@n × q@).+ -> RRRFit+reducedRankRegression r x y =+ let bOLS = x LA.<\> y -- OLS: p × q+ (u, sv, vt) = LA.svd bOLS+ r' = min r (LA.size sv)+ uR = u LA.?? (LA.All, LA.Take r')+ sR = LA.subVector 0 r' sv+ vR = (LA.tr vt) LA.?? (LA.All, LA.Take r')+ bRRR = uR LA.<> LA.diag sR LA.<> LA.tr vR+ in RRRFit bRRR uR vR r'++-- | Predict @Ŷ@ for new inputs from a 'RRRFit'.+predictRRR :: RRRFit -> LA.Matrix Double -> LA.Matrix Double+predictRRR fit xNew = xNew LA.<> rrrBeta fit++-- ---------------------------------------------------------------------------+-- Partial Least Squares (NIPALS algorithm)+-- ---------------------------------------------------------------------------++-- | PLS fit result.+data PLSFit = PLSFit+ { plsBeta :: LA.Matrix Double -- ^ Regression coefficients (@p × q@).+ , plsW :: LA.Matrix Double -- ^ Weights (@p × k@).+ , plsT :: LA.Matrix Double -- ^ Scores (@n × k@).+ , plsP :: LA.Matrix Double -- ^ Loadings (@p × k@).+ , plsQ :: LA.Matrix Double -- ^ Y-loadings (@q × k@).+ , plsK :: Int -- ^ Number of components extracted.+ } deriving (Show)++-- | NIPALS-PLS (Wold 1975). Extracts @k@ components sequentially.+--+-- For each component:+--+-- 1. @w = Xᵀ Y u / ‖Xᵀ Y u‖@ — the X-side weight (@u@ is the Y direction).+-- 2. @t = X w@.+-- 3. @p = Xᵀ t / (tᵀ t)@.+-- 4. @q = Yᵀ t / (tᵀ t)@.+-- 5. Deflate: @X ← X − t pᵀ@, @Y ← Y − t qᵀ@.+pls :: Int -- ^ Number of components @k@.+ -> LA.Matrix Double -- ^ Design matrix @X@ (@n × p@).+ -> LA.Matrix Double -- ^ Response @Y@ (@n × q@).+ -> PLSFit+pls k x0 y0 =+ let p = LA.cols x0+ q = LA.cols y0+ n = LA.rows x0+ _ = n+ go' iter xCur yCur ws ts ps qs+ | iter >= k = (reverse ws, reverse ts, reverse ps, reverse qs)+ | otherwise =+ let u = LA.flatten (yCur LA.¿ [0])+ xtyu = LA.tr xCur LA.#> u+ w = if LA.norm_2 xtyu > 1e-12+ then LA.scale (1 / LA.norm_2 xtyu) xtyu+ else LA.fromList (replicate p 0)+ t = xCur LA.#> w+ tt = max 1e-12 (LA.dot t t)+ pVec = LA.scale (1/tt) (LA.tr xCur LA.#> t)+ qVec = LA.scale (1/tt) (LA.tr yCur LA.#> t)+ xNew = xCur - LA.outer t pVec+ yNew = yCur - LA.outer t qVec+ in go' (iter + 1) xNew yNew (w:ws) (t:ts) (pVec:ps) (qVec:qs)+ (wsL, tsL, psL, qsL) = go' 0 x0 y0 [] [] [] []+ wM = LA.fromColumns wsL -- p × k+ tM = LA.fromColumns tsL -- n × k+ pM = LA.fromColumns psL -- p × k+ qM = LA.fromColumns qsL -- q × k+ -- 回帰係数: B = W (PᵀW)⁻¹ Qᵀ (Wold formula)+ ptw = LA.tr pM LA.<> wM -- k × k+ bMat = wM LA.<> LA.inv ptw LA.<> LA.tr qM -- p × q+ _ = q+ in PLSFit bMat wM tM pM qM k++-- | Predict @Ŷ@ for new inputs from a 'PLSFit'.+predictPLS :: PLSFit -> LA.Matrix Double -> LA.Matrix Double+predictPLS fit xNew = xNew LA.<> plsBeta fit++-- ---------------------------------------------------------------------------+-- Canonical Correlation Analysis+-- ---------------------------------------------------------------------------++-- | CCA fit result.+data CCAFit = CCAFit+ { ccaA :: LA.Matrix Double -- ^ X-side basis (@p × r@).+ , ccaB :: LA.Matrix Double -- ^ Y-side basis (@q × r@).+ , ccaCorr :: LA.Vector Double -- ^ Canonical correlations (length @r@).+ , ccaScoresX :: LA.Matrix Double -- ^ X scores (@n × r@).+ , ccaScoresY :: LA.Matrix Double -- ^ Y scores (@n × r@).+ } deriving (Show)++-- | Canonical Correlation Analysis: find basis pairs @(a_k, b_k)@ that+-- maximize the correlation between @X@ and @Y@.+--+-- Algorithm:+--+-- 1. Compute @C_xx = XᵀX/(n-1)@, @C_yy@, @C_xy@.+-- 2. SVD of @M = C_xx^{−1/2} C_xy C_yy^{−1/2}@: @M = U Σ Vᵀ@.+-- 3. @a = C_xx^{−1/2} U@, @b = C_yy^{−1/2} V@, correlations = @Σ@.+cca :: LA.Matrix Double -> LA.Matrix Double -> CCAFit+cca x y =+ let n = fromIntegral (LA.rows x) :: Double+ _p = LA.cols x+ _q = LA.cols y+ -- 中心化+ meanCol m = LA.fromList [LA.sumElements (LA.flatten (m LA.¿ [j])) / n+ | j <- [0 .. LA.cols m - 1]]+ mxs = meanCol x+ mys = meanCol y+ cx0 i = LA.flatten (x LA.¿ [i]) - LA.scalar (mxs LA.! i)+ cy0 i = LA.flatten (y LA.¿ [i]) - LA.scalar (mys LA.! i)+ xC = LA.fromColumns [cx0 i | i <- [0 .. LA.cols x - 1]]+ yC = LA.fromColumns [cy0 i | i <- [0 .. LA.cols y - 1]]+ -- 共分散+ cxx = LA.scale (1 / (n - 1)) (LA.tr xC LA.<> xC)+ cyy = LA.scale (1 / (n - 1)) (LA.tr yC LA.<> yC)+ cxy = LA.scale (1 / (n - 1)) (LA.tr xC LA.<> yC)+ -- 平方根逆行列 (固有値分解で計算)+ invSqrt sym =+ let (eigs, evec) = LA.eigSH (LA.sym sym)+ invSqrtVals = LA.fromList+ [ if v > 1e-12 then 1 / sqrt v else 0+ | v <- LA.toList eigs ]+ in evec LA.<> LA.diag invSqrtVals LA.<> LA.tr evec+ cxxIS = invSqrt cxx+ cyyIS = invSqrt cyy+ mMat = cxxIS LA.<> cxy LA.<> cyyIS+ (uM, sM, vtM) = LA.svd mMat+ aMat = cxxIS LA.<> uM+ bMat = cyyIS LA.<> LA.tr vtM+ scoresX = xC LA.<> aMat+ scoresY = yC LA.<> bMat+ in CCAFit aMat bMat sM scoresX scoresY
+ src/Hanalyze/Model/NaiveBayes.hs view
@@ -0,0 +1,182 @@+{-# LANGUAGE BangPatterns #-}+-- |+-- Module : Hanalyze.Model.NaiveBayes+-- Description : Naive Bayes 分類 (Gaussian + Multinomial)+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- [日本語]: Naive Bayes 分類 (Gaussian + Multinomial).+--+-- @+-- import qualified Hanalyze.Model.NaiveBayes as NB+-- let nb = NB.fitGNB x y -- 連続特徴: Gaussian+-- yhat = NB.predictNB nb x+--+-- let mnb = NB.fitMNB 1.0 xCounts yCount -- カウント特徴: Multinomial (Laplace α)+-- @+--+-- [English]: Naive Bayes classification (Gaussian + Multinomial).+--+-- @+-- import qualified Hanalyze.Model.NaiveBayes as NB+-- let nb = NB.fitGNB x y -- continuous features: Gaussian+-- yhat = NB.predictNB nb x+--+-- let mnb = NB.fitMNB 1.0 xCounts yCount -- count features: Multinomial (Laplace α)+-- @+module Hanalyze.Model.NaiveBayes+ ( -- * Gaussian NB+ GaussianNB (..)+ , fitGNB+ -- * Multinomial NB+ , MultinomialNB (..)+ , fitMNB+ -- * Predict (両対応)+ , NBModel (..)+ , predictNB+ , predictNBLogProbs+ ) where++import qualified Data.Vector.Unboxed as VU+import qualified Numeric.LinearAlgebra as LA+import Data.Text (Text)+import Data.List (nub, sort, foldl')++-- ---------------------------------------------------------------------------+-- Gaussian NB+-- ---------------------------------------------------------------------------++-- | [日本語]: クラスごとに各特徴を独立 Gaussian と仮定。+-- [English]: Assumes each feature is an independent Gaussian within each+-- class.+data GaussianNB = GaussianNB+ { gnbClasses :: ![Int]+ , gnbLogPrior :: ![Double] -- ^ [日本語]: log π_c (classes 順) [English]: log π_c (in class order).+ , gnbMeans :: ![LA.Vector Double] -- ^ [日本語]: 各クラスの μ (length d) [English]: The μ of each class (length d).+ , gnbVars :: ![LA.Vector Double] -- ^ [日本語]: 各クラスの σ² (length d)、 var smoothing 済 [English]: The σ² of each class (length d), var-smoothed.+ , gnbClassNames :: ![Text] -- ^ [日本語]: クラス名 (df|-> が levels 注入・空=数値表示)。 [English]: Class names (df|-> injects the levels; empty means numeric display).+ } deriving (Show)++-- | [日本語]: sklearn 互換の var smoothing (最大 var の 1e-9 倍を全 var に加算)。+-- [English]: sklearn-compatible var smoothing (adds 1e-9 times the maximum+-- variance to every variance).+varSmoothing :: Double+varSmoothing = 1e-9++fitGNB :: LA.Matrix Double -> VU.Vector Int -> GaussianNB+fitGNB x y =+ let !n = VU.length y+ !d = LA.cols x+ classes = sort (nub (VU.toList y))+ rows c = [ i | i <- [0 .. n - 1], y VU.! i == c ]+ meanV ids =+ let m = LA.fromRows [ LA.flatten (x LA.? [i]) | i <- ids ]+ nc = fromIntegral (length ids) :: Double+ in LA.scale (1 / nc) (LA.fromList (map LA.sumElements (LA.toColumns m)))+ varV ids mu =+ let nc = fromIntegral (length ids) :: Double+ sq i = let r = LA.flatten (x LA.? [i]) - mu+ in r * r+ sumSq = sum (map sq ids)+ in LA.scale (1 / nc) sumSq+ mus = [ meanV (rows c) | c <- classes ]+ vrs0 = zipWith (\c mu -> varV (rows c) mu) classes mus+ maxVar = maximum (map (LA.maxElement . LA.cmap abs) vrs0)+ eps = varSmoothing * maxVar + 1e-300+ vrs = map (LA.cmap (+ eps)) vrs0+ priors = [ log (fromIntegral (length (rows c)) / fromIntegral n)+ | c <- classes ]+ _ = d -- d は使わない (内部で LA.size に頼る)+ in GaussianNB classes priors mus vrs []++-- | log p(x | c) = -1/2 Σ_j [ log(2π σ²_j) + (x_j - μ_j)² / σ²_j ]+gnbLogLik :: GaussianNB -> LA.Vector Double -> [Double]+gnbLogLik nb xv =+ [ let r = xv - mu+ rsq = r * r+ logT = LA.sumElements (LA.cmap log (LA.scale (2 * pi) vr))+ chiT = LA.sumElements (rsq / vr)+ in -0.5 * (logT + chiT)+ | (mu, vr) <- zip (gnbMeans nb) (gnbVars nb) ]++-- ---------------------------------------------------------------------------+-- Multinomial NB+-- ---------------------------------------------------------------------------++-- | [日本語]: テキスト分類等のカウント特徴用。 ラプラス平滑化 α (典型 1.0)。+-- [English]: For count features such as in text classification. Laplace+-- smoothing α (typically 1.0).+data MultinomialNB = MultinomialNB+ { mnbClasses :: ![Int]+ , mnbLogPrior :: ![Double]+ , mnbLogFeat :: ![LA.Vector Double] -- ^ log p(feature_j | c)+ , mnbClassNames :: ![Text] -- ^ [日本語]: クラス名 (df|-> が levels 注入・空=数値表示)。 [English]: Class names (df|-> injects the levels; empty means numeric display).+ } deriving (Show)++fitMNB :: Double -- ^ Laplace α+ -> LA.Matrix Double -- ^ [日本語]: 非負カウント (n × d) [English]: Non-negative counts (n × d).+ -> VU.Vector Int -- ^ y+ -> MultinomialNB+fitMNB alpha x y =+ let !n = VU.length y+ !d = LA.cols x+ classes = sort (nub (VU.toList y))+ rows c = [ i | i <- [0 .. n - 1], y VU.! i == c ]+ sumRows ids =+ foldl' (+) (LA.konst 0 d)+ [ LA.flatten (x LA.? [i]) | i <- ids ]+ featLog c =+ let s = sumRows (rows c)+ !sNum = LA.cmap (+ alpha) s+ !tot = LA.sumElements sNum+ in LA.cmap log (LA.scale (1 / tot) sNum)+ priors = [ log (fromIntegral (length (rows c)) / fromIntegral n)+ | c <- classes ]+ in MultinomialNB classes priors [ featLog c | c <- classes ] []++mnbLogLik :: MultinomialNB -> LA.Vector Double -> [Double]+mnbLogLik nb xv =+ [ LA.dot xv lf | lf <- mnbLogFeat nb ]++-- ---------------------------------------------------------------------------+-- 共通インターフェース+-- ---------------------------------------------------------------------------++data NBModel = NBGaussian GaussianNB | NBMultinomial MultinomialNB+ deriving (Show)++nbClasses :: NBModel -> [Int]+nbClasses (NBGaussian m) = gnbClasses m+nbClasses (NBMultinomial m) = mnbClasses m++nbLogPriorAndLik :: NBModel -> LA.Vector Double -> ([Double], [Double])+nbLogPriorAndLik (NBGaussian m) xv = (gnbLogPrior m, gnbLogLik m xv)+nbLogPriorAndLik (NBMultinomial m) xv = (mnbLogPrior m, mnbLogLik m xv)++predictNBLogProbs :: NBModel -> LA.Matrix Double -> [[Double]]+predictNBLogProbs nb x =+ let !n = LA.rows x+ row i = LA.flatten (x LA.? [i])+ logits xv =+ let (lp, ll) = nbLogPriorAndLik nb xv+ in zipWith (+) lp ll+ -- log-sum-exp 正規化+ lse zs =+ let !mx = maximum zs+ in mx + log (sum [ exp (z - mx) | z <- zs ])+ one i =+ let zs = logits (row i)+ z = lse zs+ in [ k - z | k <- zs ]+ in [ one i | i <- [0 .. n - 1] ]++predictNB :: NBModel -> LA.Matrix Double -> VU.Vector Int+predictNB nb x =+ let probs = predictNBLogProbs nb x+ classes = nbClasses nb+ pick zs =+ let (cMax, _) = foldr1+ (\(c, v) (c', v') -> if v >= v' then (c, v) else (c', v'))+ (zip classes zs)+ in cMax+ in VU.fromList (map pick probs)
+ src/Hanalyze/Model/NeuralNetwork.hs view
@@ -0,0 +1,543 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE BangPatterns #-}+-- |+-- Module : Hanalyze.Model.NeuralNetwork+-- Description : Multi-Layer Perceptron (MLP) — feedforward neural network (mini-batch SGD + Adam)+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- [日本語]: Multi-Layer Perceptron (MLP) — feedforward neural network。+--+-- Mini-batch SGD + 自前 Adam で学習。 hmatrix Matrix/Vector で全演算。+--+-- 対応:+--+-- - 'fitMLPRegressor': 出力 1 次元の回帰 (MSE loss)+-- - 'fitMLPClassifier': 多クラス分類 (cross-entropy + softmax 出力)+-- - 'predictMLP': forward 推論+--+-- 隠れ層の活性化は ReLU 既定、 出力層は task に応じて自動 (回帰=Identity、+-- 分類=Softmax)。+--+-- [English]: Multi-Layer Perceptron (MLP) — a feedforward neural network.+--+-- Trained with mini-batch SGD + a self-contained Adam implementation.+-- All operations use hmatrix Matrix\/Vector.+--+-- Supports:+--+-- - 'fitMLPRegressor': single-output regression (MSE loss)+-- - 'fitMLPClassifier': multiclass classification (cross-entropy ++-- softmax output)+-- - 'predictMLP': forward inference+--+-- Hidden-layer activation defaults to ReLU; the output layer is chosen+-- automatically based on the task (regression=Identity,+-- classification=Softmax).+module Hanalyze.Model.NeuralNetwork+ ( Activation (..)+ , MLPConfig (..)+ , defaultMLP+ , Layer (..)+ , MLPFit (..)+ , MLPEpochEvent (..)+ , fitMLPRegressor+ , fitMLPRegressorWithCallback+ , fitMLPRegressorPure+ , fitMLPClassifier+ , fitMLPClassifierWithCallback+ , fitMLPClassifierPure+ , predictMLP+ , predictMLPClass+ ) where++import qualified Data.Vector as V+import qualified Data.Vector.Unboxed as VU+import Data.Text (Text)+import qualified Numeric.LinearAlgebra as LA+import Control.Monad (forM_)+import Control.Monad.Primitive (PrimMonad, PrimState)+import Control.Monad.ST (runST)+import Data.Primitive.MutVar (newMutVar, readMutVar, writeMutVar,+ modifyMutVar')+import Data.Word (Word32)+import qualified System.Random.MWC as MWC+import System.Random.MWC (initialize)+import System.Random.MWC.Distributions (standard)++-- ===========================================================================+-- 型+-- ===========================================================================++data Activation = ReLU | Sigmoid | Tanh | Identity | Softmax+ deriving (Show, Eq)++data Layer = Layer+ { lyrW :: !(LA.Matrix Double) -- (in × out)+ , lyrB :: !(LA.Vector Double) -- (out)+ , lyrAct :: !Activation+ } deriving (Show)++data MLPConfig = MLPConfig+ { mlpHidden :: ![Int]+ , mlpActHidden :: !Activation+ , mlpLR :: !Double+ , mlpEpochs :: !Int+ , mlpBatch :: !Int+ , mlpL2 :: !Double+ , mlpStandardize :: !Bool+ -- ^ [日本語]: True で X を z-score 標準化してから学習 (predict 時は同じ+ -- mean/std で逆変換)。 default True。+ -- [English]: When True, z-score standardizes X before training+ -- (predict inverts using the same mean\/std). Default True.+ } deriving (Show)++defaultMLP :: MLPConfig+defaultMLP = MLPConfig+ { mlpHidden = [16]+ , mlpActHidden = ReLU+ , mlpLR = 0.01+ , mlpEpochs = 200+ , mlpBatch = 16+ , mlpL2 = 0+ , mlpStandardize = True+ }++data MLPFit = MLPFit+ { mlpLayers :: ![Layer]+ , mlpLossHist :: ![Double]+ , mlpClasses :: ![Int]+ -- ^ [日本語]: 分類器の場合の class label 順 (sorted)。 回帰時は空。+ -- [English]: Class label order (sorted), for a classifier. Empty for regression.+ , mlpClassNames :: ![Text]+ -- ^ [日本語]: クラス名 (df|-> が levels 注入・空=数値表示/回帰時は空)。+ -- [English]: Class names (injected as levels by df|->; empty means+ -- numeric display, or empty for regression).+ , mlpXMean :: !(LA.Vector Double)+ -- ^ [日本語]: X 標準化に使った列平均 (標準化 off なら length 0)+ -- [English]: Column means used for X standardization (length 0 if standardization is off)+ , mlpXStd :: !(LA.Vector Double)+ , mlpYMean :: !Double+ -- ^ [日本語]: regressor の場合の y 平均 (標準化 off なら 0)+ -- [English]: y mean, for a regressor (0 if standardization is off)+ , mlpYStd :: !Double+ } deriving (Show)++-- ===========================================================================+-- 活性化+-- ===========================================================================++applyAct :: Activation -> LA.Matrix Double -> LA.Matrix Double+applyAct ReLU = LA.cmap (\v -> max 0 v)+applyAct Sigmoid = LA.cmap (\v -> 1 / (1 + exp (-v)))+applyAct Tanh = LA.cmap tanh+applyAct Identity = id+applyAct Softmax = softmaxRows++actGrad :: Activation -> LA.Matrix Double -> LA.Matrix Double -> LA.Matrix Double+actGrad ReLU pre _ = LA.cmap (\v -> if v > 0 then 1 else 0) pre+actGrad Sigmoid _ out = out * (1 - out)+actGrad Tanh _ out = 1 - out * out+actGrad Identity _ _ = LA.fromLists [[1 :: Double]]+actGrad Softmax _ _ = LA.fromLists [[1 :: Double]]++softmaxRows :: LA.Matrix Double -> LA.Matrix Double+softmaxRows m = LA.fromRows+ [ let r = LA.flatten (m LA.? [i])+ mx = LA.maxElement r+ ex = LA.cmap (\v -> exp (v - mx)) r+ s = LA.sumElements ex+ in LA.scale (1 / s) ex+ | i <- [0 .. LA.rows m - 1] ]++-- ===========================================================================+-- 初期化+-- ===========================================================================++initLayers :: PrimMonad m+ => MWC.Gen (PrimState m) -> Int -> Int -> [Int] -> Activation -> Activation -> m [Layer]+initLayers gen inDim outDim hidden hidAct outAct = do+ let sizes = inDim : hidden ++ [outDim]+ pairs = zip sizes (tail sizes)+ acts = replicate (length hidden) hidAct ++ [outAct]+ mapM (\((nin, nout), act) -> do+ let scale = sqrt (2 / fromIntegral nin)+ ws <- mapM (\_ -> standard gen) [1 .. nin * nout]+ let w = LA.scale scale+ (LA.fromLists (chunksOf nout ws))+ b = LA.fromList (replicate nout 0)+ pure (Layer w b act))+ (zip pairs acts)+ where+ chunksOf _ [] = []+ chunksOf n xs = take n xs : chunksOf n (drop n xs)++-- ===========================================================================+-- Forward pass+-- ===========================================================================++forward :: [Layer] -> LA.Matrix Double -> [(LA.Matrix Double, LA.Matrix Double)]+forward layers x = go x layers []+ where+ go _ [] acc = reverse acc+ go inp (l:ls) acc =+ let pre = addBias (inp LA.<> lyrW l) (lyrB l)+ out = applyAct (lyrAct l) pre+ in go out ls ((pre, out) : acc)++-- | Add bias vector (length = out) to every row of the (n × out) matrix.+addBias :: LA.Matrix Double -> LA.Vector Double -> LA.Matrix Double+addBias m b = m + LA.fromRows (replicate (LA.rows m) b)++-- ===========================================================================+-- Backprop (回帰 MSE)+-- ===========================================================================++-- | Backprop with MSE for regression OR cross-entropy with softmax for+-- classification. Output gradient at last layer differs by task:+-- reg: dL/dz_out = (yhat - y) / n (with Identity output)+-- class: dL/dz_out = (yhat - yOH) / n (softmax + CE simplification)+backprop+ :: [Layer]+ -> LA.Matrix Double -- x (n × in)+ -> LA.Matrix Double -- y (n × out) target+ -> Bool -- True = classification (softmax+CE)+ -> Double -- L2 weight+ -> [(LA.Matrix Double, LA.Vector Double)] -- gradients (dW, dB) per layer+backprop layers x y isClass l2 =+ let cache = forward layers x -- list of (pre, out) per layer+ n = fromIntegral (LA.rows x) :: Double+ out_ = snd (last cache)+ dPre_last+ | isClass = LA.scale (1/n) (out_ - y)+ | otherwise = LA.scale (1/n) (out_ - y) -- Identity output, same shape+ -- walk backward+ walk !dPre [] _ acc = acc+ walk !dPre (l:ls) (c:cs) acc =+ let -- input to layer l = (previous out) or x if first+ inpToL = case cs of+ [] -> x+ (cPrev:_) -> snd cPrev+ (preL, _) = c+ dW = LA.tr inpToL LA.<> dPre + LA.scale l2 (lyrW l)+ dB = LA.fromList [ LA.sumElements (dPre LA.¿ [j])+ | j <- [0 .. LA.cols dPre - 1] ]+ -- propagate to previous layer+ dOutPrev = dPre LA.<> LA.tr (lyrW l)+ dPrePrev =+ case ls of+ [] -> dOutPrev -- unused+ (lPrev:_) ->+ let (prePrev, outPrev) = head cs+ g = actGrad (lyrAct lPrev) prePrev outPrev+ in dOutPrev * g+ in walk dPrePrev ls cs ((dW, dB) : acc)+ grads = walk dPre_last (reverse layers) (reverse cache) []+ in grads++-- ===========================================================================+-- 学習ループ (Adam)+-- ===========================================================================++-- | [日本語]: 'fitMLPRegressorWithCallback' / 'fitMLPClassifierWithCallback'+-- が発行する epoch ごとのイベント。+-- [English]: Per-epoch event emitted by 'fitMLPRegressorWithCallback' \/+-- 'fitMLPClassifierWithCallback'.+data MLPEpochEvent = MLPEpochEvent+ { meEpoch :: !Int+ -- ^ [日本語]: 0-based epoch index (0..epochs-1) [English]: 0-based epoch index (0..epochs-1)+ , meTrainLoss :: !Double+ -- ^ [日本語]: epoch 終端での full-batch training loss [English]: Full-batch training loss at the end of the epoch+ , meValLoss :: !(Maybe Double)+ -- ^ [日本語]: validation split loss。 v1 では常に 'Nothing' (= reserved for future)+ -- [English]: Validation-split loss. Always 'Nothing' in v1 (reserved for future use).+ , meCurrentLR :: !Double+ -- ^ [日本語]: そのときの学習率 (現在は constant scheduler のみ、 将来 LR scheduler+ -- 実装で意味が出る)+ -- [English]: The learning rate at that point (currently only a+ -- constant scheduler is implemented; this becomes meaningful once a+ -- future LR scheduler is implemented).+ } deriving (Show)++trainMLP+ :: PrimMonad m+ => MWC.Gen (PrimState m) -> MLPConfig+ -> LA.Matrix Double -> LA.Matrix Double+ -> Bool -- isClass+ -> (MLPEpochEvent -> m ()) -- per-epoch callback (no-op で旧挙動)+ -> m ([Layer], [Double])+trainMLP gen cfg x y isClass onEpoch = do+ let inDim = LA.cols x+ outDim = LA.cols y+ outAct = if isClass then Softmax else Identity+ layers0 <- initLayers gen inDim outDim (mlpHidden cfg) (mlpActHidden cfg) outAct+ -- Adam state per layer (mW, vW, mB, vB)+ let zeroLike w = LA.scale 0 w+ zeroLikeV v = LA.scale 0 v+ state <- mapM (\l -> do+ mw <- newMutVar (zeroLike (lyrW l))+ vw <- newMutVar (zeroLike (lyrW l))+ mb <- newMutVar (zeroLikeV (lyrB l))+ vb <- newMutVar (zeroLikeV (lyrB l))+ pure (mw, vw, mb, vb)) layers0+ layersRef <- newMutVar layers0+ lossRef <- newMutVar ([] :: [Double])+ let n = LA.rows x+ lr = mlpLR cfg+ b1 = 0.9+ b2 = 0.999+ eps = 1e-8+ tRef <- newMutVar (0 :: Int)+ forM_ [0 .. mlpEpochs cfg - 1] $ \epochIdx -> do+ -- shuffle indices+ idx <- fisherYates gen [0 .. n - 1]+ let batches = chunksOf (mlpBatch cfg) idx+ forM_ batches $ \batch -> do+ let xb = x LA.? batch+ yb = y LA.? batch+ ls0 <- readMutVar layersRef+ let grads = backprop ls0 xb yb isClass (mlpL2 cfg)+ modifyMutVar' tRef (+1)+ t <- readMutVar tRef+ let tD = fromIntegral t :: Double+ c1 = 1 - b1 ** tD+ c2 = 1 - b2 ** tD+ newLayers <-+ mapM (\(l, (dW, dB), (mwR, vwR, mbR, vbR)) -> do+ mw <- readMutVar mwR+ vw <- readMutVar vwR+ mb <- readMutVar mbR+ vb <- readMutVar vbR+ let mw' = LA.scale b1 mw + LA.scale (1 - b1) dW+ vw' = LA.scale b2 vw + LA.scale (1 - b2) (dW * dW)+ mb' = LA.scale b1 mb + LA.scale (1 - b1) dB+ vb' = LA.scale b2 vb + LA.scale (1 - b2) (dB * dB)+ mwHat = LA.scale (1 / c1) mw'+ vwHat = LA.scale (1 / c2) vw'+ mbHat = LA.scale (1 / c1) mb'+ vbHat = LA.scale (1 / c2) vb'+ wNew = lyrW l - LA.scale lr+ (mwHat / LA.cmap (\v -> sqrt v + eps) vwHat)+ bNew = lyrB l - LA.scale lr+ (mbHat / LA.cmap (\v -> sqrt v + eps) vbHat)+ writeMutVar mwR mw'+ writeMutVar vwR vw'+ writeMutVar mbR mb'+ writeMutVar vbR vb'+ pure l { lyrW = wNew, lyrB = bNew })+ (zip3 ls0 grads state)+ writeMutVar layersRef newLayers+ -- record epoch loss + per-epoch callback (Phase 21)+ lsFinal <- readMutVar layersRef+ let cache = forward lsFinal x+ out_ = snd (last cache)+ loss = if isClass+ then crossEntropyLoss out_ y+ else mseLoss out_ y+ modifyMutVar' lossRef (loss :)+ onEpoch MLPEpochEvent+ { meEpoch = epochIdx+ , meTrainLoss = loss+ , meValLoss = Nothing+ , meCurrentLR = lr+ }+ finalLayers <- readMutVar layersRef+ losses <- readMutVar lossRef+ pure (finalLayers, reverse losses)++mseLoss :: LA.Matrix Double -> LA.Matrix Double -> Double+mseLoss yhat y =+ let d = yhat - y+ in LA.sumElements (d * d) / fromIntegral (LA.rows y * LA.cols y)++crossEntropyLoss :: LA.Matrix Double -> LA.Matrix Double -> Double+crossEntropyLoss yhat y =+ let safe = LA.cmap (\v -> log (max 1e-15 v)) yhat+ in - LA.sumElements (y * safe) / fromIntegral (LA.rows y)++-- ===========================================================================+-- 公開 API+-- ===========================================================================++-- | [日本語]: X の列ごと平均と標準偏差 (n-1)。+-- [English]: Per-column mean and standard deviation of X (n-1).+standardizeStats :: LA.Matrix Double -> (LA.Vector Double, LA.Vector Double)+standardizeStats x =+ let n = LA.rows x+ nD = fromIntegral n :: Double+ mean_ = LA.fromList+ [ LA.sumElements (x LA.¿ [j]) / nD | j <- [0 .. LA.cols x - 1] ]+ std_ = if n < 2+ then LA.fromList (replicate (LA.cols x) 1)+ else LA.fromList+ [ let c = LA.flatten (x LA.¿ [j]) - LA.scalar (mean_ `LA.atIndex` j)+ v = (c `LA.dot` c) / (nD - 1)+ s = sqrt v+ in if s > 1e-12 then s else 1+ | j <- [0 .. LA.cols x - 1] ]+ in (mean_, std_)++applyStandardize :: LA.Vector Double -> LA.Vector Double -> LA.Matrix Double+ -> LA.Matrix Double+applyStandardize mean_ std_ x =+ let n = LA.rows x+ mRow = LA.fromRows (replicate n mean_)+ sRow = LA.fromRows (replicate n std_)+ in (x - mRow) / sRow++fitMLPRegressor+ :: MLPConfig -> LA.Matrix Double -> LA.Vector Double+ -> MWC.GenIO -> IO MLPFit+fitMLPRegressor cfg x y gen =+ fitMLPRegressorWithCallback cfg x y gen (\_ -> pure ())++-- | [日本語]: epoch 終端ごとに 'MLPEpochEvent' を渡す callback 付き+-- regressor 学習。 既存 'fitMLPRegressor' は no-op callback で本関数を呼ぶ+-- 薄い wrapper として保持される。+-- [English]: Regressor training with a callback that is passed an+-- 'MLPEpochEvent' at the end of every epoch. The existing+-- 'fitMLPRegressor' is kept as a thin wrapper that calls this function+-- with a no-op callback.+fitMLPRegressorWithCallback+ :: PrimMonad m+ => MLPConfig -> LA.Matrix Double -> LA.Vector Double+ -> MWC.Gen (PrimState m)+ -> (MLPEpochEvent -> m ())+ -> m MLPFit+fitMLPRegressorWithCallback cfg x y gen onEpoch = do+ let (xMean, xStd) = if mlpStandardize cfg+ then standardizeStats x+ else (LA.fromList [], LA.fromList [])+ xUse = if mlpStandardize cfg then applyStandardize xMean xStd x else x+ yMat = LA.asColumn y+ (layers, losses) <- trainMLP gen cfg xUse yMat False onEpoch+ pure MLPFit+ { mlpLayers = layers+ , mlpLossHist = losses+ , mlpClasses = []+ , mlpClassNames = []+ , mlpXMean = xMean+ , mlpXStd = xStd+ , mlpYMean = 0+ , mlpYStd = 1+ }++fitMLPClassifier+ :: MLPConfig -> LA.Matrix Double -> VU.Vector Int+ -> MWC.GenIO -> IO MLPFit+fitMLPClassifier cfg x y gen =+ fitMLPClassifierWithCallback cfg x y gen (\_ -> pure ())++-- | [日本語]: 'fitMLPRegressorWithCallback' の classifier 版。+-- [English]: The classifier counterpart of 'fitMLPRegressorWithCallback'.+fitMLPClassifierWithCallback+ :: PrimMonad m+ => MLPConfig -> LA.Matrix Double -> VU.Vector Int+ -> MWC.Gen (PrimState m)+ -> (MLPEpochEvent -> m ())+ -> m MLPFit+fitMLPClassifierWithCallback cfg x y gen onEpoch = do+ let classes = uniqueSort (VU.toList y)+ k = length classes+ n = VU.length y+ classIdx c = case lookup c (zip classes [0 ..]) of+ Just i -> i+ Nothing -> 0+ yOH = LA.fromLists+ [ [ if j == classIdx (y VU.! i) then 1 else 0+ | j <- [0 .. k - 1] ]+ | i <- [0 .. n - 1] ]+ (xMean, xStd) = if mlpStandardize cfg+ then standardizeStats x+ else (LA.fromList [], LA.fromList [])+ xUse = if mlpStandardize cfg then applyStandardize xMean xStd x else x+ (layers, losses) <- trainMLP gen cfg xUse yOH True onEpoch+ pure MLPFit+ { mlpLayers = layers+ , mlpLossHist = losses+ , mlpClasses = classes+ , mlpClassNames = []+ , mlpXMean = xMean+ , mlpXStd = xStd+ , mlpYMean = 0+ , mlpYStd = 1+ }++-- | [日本語]: 'fitMLPRegressor' の純粋版。 Word32 seed から @runST@ + MWC で重み初期化・+-- shuffle を決定的に閉じる (@fitRFVPure@/@nutsPure@ と同方針・同 seed → ビット同一)。+-- IO 版は進捗 callback 用に残る。+-- [English]: A pure version of 'fitMLPRegressor'. Closes weight+-- initialization and shuffling over @runST@ + MWC from a Word32 seed,+-- deterministically (same policy as @fitRFVPure@\/@nutsPure@: same seed+-- → bit-identical). The IO version remains for progress callbacks.+fitMLPRegressorPure :: MLPConfig -> LA.Matrix Double -> LA.Vector Double -> Word32 -> MLPFit+fitMLPRegressorPure cfg x y seed =+ runST (initialize (V.singleton seed)+ >>= \gen -> fitMLPRegressorWithCallback cfg x y gen (\_ -> pure ()))++-- | [日本語]: 'fitMLPClassifier' の純粋版。 seed から @runST@ で決定的に学習。+-- [English]: A pure version of 'fitMLPClassifier'. Trains+-- deterministically via @runST@ from a seed.+fitMLPClassifierPure :: MLPConfig -> LA.Matrix Double -> VU.Vector Int -> Word32 -> MLPFit+fitMLPClassifierPure cfg x y seed =+ runST (initialize (V.singleton seed)+ >>= \gen -> fitMLPClassifierWithCallback cfg x y gen (\_ -> pure ()))++predictMLP :: MLPFit -> LA.Matrix Double -> LA.Matrix Double+predictMLP fit xNew =+ let xUse = if LA.size (mlpXMean fit) > 0+ then applyStandardize (mlpXMean fit) (mlpXStd fit) xNew+ else xNew+ cache = forward (mlpLayers fit) xUse+ raw = snd (last cache)+ -- regressor の場合、 y も標準化して学習しているので戻す+ in if null (mlpClasses fit) && mlpYStd fit /= 1+ then LA.cmap (\v -> v * mlpYStd fit + mlpYMean fit) raw+ else raw++predictMLPClass :: MLPFit -> LA.Matrix Double -> V.Vector Int+predictMLPClass fit xNew =+ let probs = predictMLP fit xNew+ classes = mlpClasses fit+ in V.generate (LA.rows probs) $ \i ->+ let row = LA.toList (LA.flatten (probs LA.? [i]))+ (best, _) = foldr1 (\(j, p) (jb, pb) ->+ if p > pb then (j, p) else (jb, pb))+ (zip [0 ..] row)+ in classes !! best++-- ===========================================================================+-- helpers+-- ===========================================================================++uniqueSort :: Ord a => [a] -> [a]+uniqueSort = uniqAdj . sortL+ where+ sortL xs = foldr insertSorted [] xs+ insertSorted x [] = [x]+ insertSorted x ys@(y:rest)+ | x < y = x : ys+ | x == y = ys+ | otherwise = y : insertSorted x rest+ uniqAdj [] = []+ uniqAdj [a] = [a]+ uniqAdj (a:b:rest)+ | a == b = uniqAdj (b : rest)+ | otherwise = a : uniqAdj (b : rest)++chunksOf :: Int -> [a] -> [[a]]+chunksOf _ [] = []+chunksOf n xs = take n xs : chunksOf n (drop n xs)++fisherYates :: PrimMonad m => MWC.Gen (PrimState m) -> [a] -> m [a]+fisherYates gen xs =+ let v0 = V.fromList xs+ in go v0 (V.length v0 - 1)+ where+ go v 0 = pure (V.toList v)+ go v i = do+ j <- MWC.uniformR (0, i) gen+ let vi = v V.! i+ vj = v V.! j+ v' = v V.// [(i, vj), (j, vi)]+ go v' (i - 1)
+ src/Hanalyze/Model/PCA.hs view
@@ -0,0 +1,180 @@+{-# 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
+ src/Hanalyze/Model/PLS.hs view
@@ -0,0 +1,440 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE BangPatterns #-}+-- |+-- Module : Hanalyze.Model.PLS+-- Description : PLS (Partial Least Squares) — 応答 Y との共分散を最大化する低ランク回帰 (NIPALS)+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- [日本語]: Partial Least Squares (PLS) — chemometrics 標準の応答連動低ランク回帰。+--+-- PCA (`Hanalyze.Model.PCA`) は応答無視の分散最大化、 PLS は+-- __応答 Y と X の共分散を最大化する__低ランク射影。 多変量分光分析 / 材料設計の+-- 予測 + 変数選択を 1 モデルで実現する。+--+-- アルゴリズム:+--+-- - 'NIPALS' (default): 反復的 power iteration、 sklearn @PLSRegression@ と+-- 数値一致しやすい+-- - 'SIMPLS' (追加予定): de Jong 1993、 SVD ベース、 multi-Y で+-- 直接的+--+-- 内部実装は hmatrix Matrix / Vector 演算で完結 (list 化しない)。+--+-- [English]: Partial Least Squares (PLS) — the chemometrics-standard+-- response-linked low-rank regression.+--+-- Whereas PCA (`Hanalyze.Model.PCA`) is response-agnostic variance+-- maximization, PLS is a low-rank projection that+-- __maximizes the covariance between the response Y and X__. It delivers+-- prediction + variable selection for multivariate spectroscopy \/+-- materials design in a single model.+--+-- Algorithms:+--+-- - 'NIPALS' (default): iterative power iteration, which tends to match+-- sklearn's @PLSRegression@ numerically.+-- - 'SIMPLS' (planned addition): de Jong 1993, SVD-based, direct for+-- multi-Y.+--+-- The internal implementation is self-contained in hmatrix+-- Matrix \/ Vector operations (never converts to lists).+module Hanalyze.Model.PLS+ ( -- * Config+ PLSAlgorithm (..)+ , PLSConfig (..)+ , defaultPLS+ -- * Fit / predict+ , PLSFit (..)+ , fitPLS+ , fitPLS1+ , predictPLS+ , predictPLS1+ -- * CV による component 数選択+ , PLSLambdaSelection (..)+ , selectPLSComponentsCV+ ) where++import qualified Numeric.LinearAlgebra as LA+import qualified System.Random.MWC as MWC+import Data.List (sortBy)+import Data.Ord (comparing)+import Data.Text (Text)+import qualified Data.Text as T++import qualified Hanalyze.Stat.CV as HCV++-- ===========================================================================+-- Config+-- ===========================================================================++data PLSAlgorithm+ = NIPALS -- ^ [日本語]: 反復的 power iteration (default)。 [English]: Iterative power iteration (default).+ | SIMPLS -- ^ [日本語]: de Jong 1993 (追加予定)。 [English]: de Jong 1993 (a planned addition).+ deriving (Show, Eq)++data PLSConfig = PLSConfig+ { plsN_Components :: !Int+ , plsAlgorithm :: !PLSAlgorithm+ , plsScale :: !Bool -- ^ [日本語]: True で X, Y を column-wise に標準化。 [English]: When True, standardizes X and Y column-wise.+ , plsTol :: !Double -- ^ [日本語]: NIPALS 収束許容誤差。 [English]: The NIPALS convergence tolerance.+ , plsMaxIter :: !Int -- ^ [日本語]: NIPALS 最大反復。 [English]: The NIPALS maximum iteration count.+ } deriving (Show)++defaultPLS :: PLSConfig+defaultPLS = PLSConfig+ { plsN_Components = 2+ , plsAlgorithm = NIPALS+ , plsScale = True+ , plsTol = 1e-8+ , plsMaxIter = 500+ }++-- ===========================================================================+-- 結果型+-- ===========================================================================++data PLSFit = PLSFit+ { plsScoresT :: !(LA.Matrix Double) -- ^ [日本語]: T (n × K) X scores。 [English]: T (n × K), the X scores.+ , plsLoadingsP :: !(LA.Matrix Double) -- ^ [日本語]: P (p × K) X loadings。 [English]: P (p × K), the X loadings.+ , plsLoadingsQ :: !(LA.Matrix Double) -- ^ [日本語]: Q (q × K) Y loadings。 [English]: Q (q × K), the Y loadings.+ , plsWeightsW :: !(LA.Matrix Double) -- ^ [日本語]: W (p × K) X weights。 [English]: W (p × K), the X weights.+ , plsCoef :: !(LA.Matrix Double)+ -- ^ [日本語]: β (p × q) 回帰係数 (元スケール)。 @Ŷ = (X - X̄) · β + Ȳ@。+ -- [English]: β (p × q), the regression coefficients (original scale).+ -- @Ŷ = (X - X̄) · β + Ȳ@.+ , plsXMean :: !(LA.Vector Double) -- ^ [日本語]: X 列平均。 [English]: The X column means.+ , plsXStd :: !(LA.Vector Double) -- ^ [日本語]: X 列標準偏差 (plsScale=True なら、 そうでなければ 1)。 [English]: The X column standard deviations (when plsScale=True; otherwise 1).+ , plsYMean :: !(LA.Vector Double) -- ^ [日本語]: Y 列平均。 [English]: The Y column means.+ , plsYStd :: !(LA.Vector Double) -- ^ [日本語]: Y 列標準偏差 (plsScale=True なら、 そうでなければ 1)。 [English]: The Y column standard deviations (when plsScale=True; otherwise 1).+ , plsR2X :: !(LA.Vector Double) -- ^ [日本語]: 各 component の X 説明分散率。 [English]: The fraction of X variance explained by each component.+ , plsR2Y :: !(LA.Vector Double) -- ^ [日本語]: 各 component の Y 説明分散率。 [English]: The fraction of Y variance explained by each component.+ , plsVIP :: !(LA.Vector Double) -- ^ [日本語]: 変数重要度 (Variable Importance in Projection)。 [English]: Variable importance (Variable Importance in Projection).+ , plsConfig :: !PLSConfig+ } deriving (Show)++-- ===========================================================================+-- 公開関数+-- ===========================================================================++-- | [日本語]: PLS fit (multi-output Y、 q ≥ 1)。+-- [English]: PLS fit (multi-output Y, q ≥ 1).+fitPLS :: PLSConfig+ -> LA.Matrix Double -- ^ [日本語]: X (n × p)。 [English]: X (n × p).+ -> LA.Matrix Double -- ^ [日本語]: Y (n × q)。 [English]: Y (n × q).+ -> Either Text PLSFit+fitPLS cfg x y+ | LA.rows x /= LA.rows y =+ Left "fitPLS: X and Y must have the same number of rows"+ | LA.rows x < 2 =+ Left "fitPLS: need at least 2 observations"+ | plsN_Components cfg < 1 =+ Left "fitPLS: n_components must be ≥ 1"+ | plsN_Components cfg > min (LA.rows x - 1) (LA.cols x) =+ Left (T.pack ("fitPLS: n_components (" <> show (plsN_Components cfg) <>+ ") exceeds min(n-1, p)"))+ | otherwise =+ case plsAlgorithm cfg of+ NIPALS -> Right (nipalsFit cfg x y)+ SIMPLS -> Left "fitPLS: SIMPLS not yet implemented (Phase 9.5)"++-- | [日本語]: 単出力 Y ショートカット (q = 1)。+-- [English]: A single-output Y shortcut (q = 1).+fitPLS1 :: PLSConfig -> LA.Matrix Double -> LA.Vector Double -> Either Text PLSFit+fitPLS1 cfg x y = fitPLS cfg x (LA.asColumn y)++-- | [日本語]: 予測 (multi-output)。 `plsCoef` は元スケールの回帰係数なので、+-- X を中央化するだけで予測可能 (= scaling は不要、 coef が吸収済)。+-- [English]: Predicts (multi-output). Since `plsCoef` is the regression+-- coefficient on the original scale, prediction only requires+-- centering X (no scaling needed — it is already absorbed into coef).+predictPLS :: PLSFit -> LA.Matrix Double -> LA.Matrix Double+predictPLS fit xNew =+ let nRow = LA.rows xNew+ xCentered = xNew - LA.fromRows (replicate nRow (plsXMean fit))+ yCentered = xCentered LA.<> plsCoef fit+ in yCentered + LA.fromRows (replicate nRow (plsYMean fit))++predictPLS1 :: PLSFit -> LA.Matrix Double -> LA.Vector Double+predictPLS1 fit xNew = LA.flatten (predictPLS fit xNew)++-- ===========================================================================+-- NIPALS 実装+-- ===========================================================================++-- | [日本語]: NIPALS 内部実装。 中央化 + (option で) 標準化 → component loop → 後処理。+-- [English]: The internal NIPALS implementation: centering + (optionally)+-- standardization → the component loop → post-processing.+nipalsFit :: PLSConfig -> LA.Matrix Double -> LA.Matrix Double -> PLSFit+nipalsFit cfg xRaw yRaw =+ let !n = LA.rows xRaw+ !p = LA.cols xRaw+ !q = LA.cols yRaw+ k = plsN_Components cfg++ -- 列平均+ xMean = LA.scale (1 / fromIntegral n) (LA.fromList+ [ LA.sumElements (xRaw LA.¿ [j]) | j <- [0 .. p - 1] ])+ yMean = LA.scale (1 / fromIntegral n) (LA.fromList+ [ LA.sumElements (yRaw LA.¿ [j]) | j <- [0 .. q - 1] ])++ xCentered = xRaw - LA.fromRows (replicate n xMean)+ yCentered = yRaw - LA.fromRows (replicate n yMean)++ -- 列標準偏差 (n-1 分母、 plsScale=False なら 1 ベクトル)+ -- Bug fix (Phase 17.1): 旧実装 LA.sumElements (c LA.<> LA.tr c) は+ -- n×n 行列 c_i c_j を生成 → sumElements で (Σ c)² になっていた。+ -- 正しくは Σ c_i² = c `LA.dot` c。+ colSD m mean_+ | LA.rows m < 2 = LA.fromList (replicate (LA.cols m) 1)+ | otherwise =+ let nm = fromIntegral (LA.rows m - 1) :: Double+ centered = m - LA.fromRows (replicate (LA.rows m) mean_)+ sqSum = LA.fromList+ [ let c = LA.flatten (centered LA.¿ [j])+ in c `LA.dot` c+ | j <- [0 .. LA.cols m - 1] ]+ in LA.cmap (\v -> let s = sqrt (v / nm) in if s > 1e-12 then s else 1) sqSum++ xStd = if plsScale cfg then colSD xRaw xMean else LA.fromList (replicate p 1)+ yStd = if plsScale cfg then colSD yRaw yMean else LA.fromList (replicate q 1)++ xScaled = if plsScale cfg+ then xCentered / LA.fromRows (replicate n xStd)+ else xCentered+ yScaled = if plsScale cfg+ then yCentered / LA.fromRows (replicate n yStd)+ else yCentered++ -- component loop: 各 component で deflate しながら w, t, p, q を取り出す+ (wMat, tMat, pMat, qMat) = nipalsLoop cfg k xScaled yScaled++ -- 回帰係数 β = W (Pᵀ W)⁻¹ Qᵀ (centered/scaled 空間)+ ptw = LA.tr pMat LA.<> wMat -- K × K+ ptwInv = case LA.linearSolve ptw (LA.ident k) of+ Just inv -> inv+ Nothing -> LA.scale 0 (LA.ident k) -- singular なら 0+ betaScaled = wMat LA.<> ptwInv LA.<> LA.tr qMat -- p × q++ -- R²X, R²Y を component 別に計算+ ssTotalX = LA.sumElements (xScaled * xScaled)+ ssTotalY = LA.sumElements (yScaled * yScaled)+ r2X = LA.fromList+ [ let tk = tMat LA.¿ [j]+ pk = pMat LA.¿ [j]+ recon = tk LA.<> LA.tr pk+ ss = LA.sumElements (recon * recon)+ in if ssTotalX > 0 then ss / ssTotalX else 0+ | j <- [0 .. k - 1] ]+ r2Y = LA.fromList+ [ let tk = tMat LA.¿ [j]+ qk = qMat LA.¿ [j]+ recon = tk LA.<> LA.tr qk+ ss = LA.sumElements (recon * recon)+ in if ssTotalY > 0 then ss / ssTotalY else 0+ | j <- [0 .. k - 1] ]++ -- VIP: VIP_j = sqrt( p · Σ_k (W²_jk · SS_Y_k) / Σ_k SS_Y_k )+ ssYPerComp = LA.fromList+ [ let tk = tMat LA.¿ [j]+ qk = qMat LA.¿ [j]+ recon = tk LA.<> LA.tr qk+ in LA.sumElements (recon * recon)+ | j <- [0 .. k - 1] ]+ ssYTotal = LA.sumElements ssYPerComp+ vip = if ssYTotal > 0+ then LA.fromList+ [ let wj = LA.flatten (LA.tr wMat LA.¿ [j]) -- length K+ contribs = (wj * wj) * ssYPerComp+ total = LA.sumElements contribs+ in sqrt (fromIntegral p * total / ssYTotal)+ | j <- [0 .. p - 1] ]+ else LA.fromList (replicate p 0)++ -- 元スケールの coef+ coefOrig =+ if plsScale cfg+ then let xStdInv = LA.cmap (1 /) xStd+ yStdDiag = LA.diag yStd+ xStdDiagInv = LA.diag xStdInv+ in xStdDiagInv LA.<> betaScaled LA.<> yStdDiag+ else betaScaled++ in PLSFit+ { plsScoresT = tMat+ , plsLoadingsP = pMat+ , plsLoadingsQ = qMat+ , plsWeightsW = wMat+ , plsCoef = coefOrig+ , plsXMean = xMean+ , plsXStd = xStd+ , plsYMean = yMean+ , plsYStd = yStd+ , plsR2X = r2X+ , plsR2Y = r2Y+ , plsVIP = vip+ , plsConfig = cfg+ }++-- | [日本語]: NIPALS 反復ループ: scaled X, Y から K components を抽出。+-- [English]: The NIPALS iteration loop: extracts K components from+-- scaled X, Y.+nipalsLoop+ :: PLSConfig+ -> Int -- K+ -> LA.Matrix Double -- X_scaled (n × p)+ -> LA.Matrix Double -- Y_scaled (n × q)+ -> ( LA.Matrix Double -- W (p × K)+ , LA.Matrix Double -- T (n × K)+ , LA.Matrix Double -- P (p × K)+ , LA.Matrix Double -- Q (q × K)+ )+nipalsLoop cfg k x0 y0 = go 0 x0 y0 [] [] [] []+ where+ go !i !x !y wAcc tAcc pAcc qAcc+ | i >= k =+ ( LA.fromColumns (reverse wAcc)+ , LA.fromColumns (reverse tAcc)+ , LA.fromColumns (reverse pAcc)+ , LA.fromColumns (reverse qAcc)+ )+ | otherwise =+ let (w, t, ploading, qloading) = nipalsOneComponent cfg x y+ -- Deflate: E = E - t pᵀ, F = F - t qᵀ+ x' = x - LA.asColumn t LA.<> LA.asRow ploading+ y' = y - LA.asColumn t LA.<> LA.asRow qloading+ in go (i + 1) x' y' (w : wAcc) (t : tAcc) (ploading : pAcc) (qloading : qAcc)++-- | [日本語]: NIPALS の 1 component 抽出。 power iteration で w, t, p, q を得る。+-- [English]: Extracts a single NIPALS component. Obtains w, t, p, q via+-- power iteration.+nipalsOneComponent+ :: PLSConfig+ -> LA.Matrix Double+ -> LA.Matrix Double+ -> ( LA.Vector Double -- w (p)+ , LA.Vector Double -- t (n)+ , LA.Vector Double -- p loading (p)+ , LA.Vector Double -- q loading (q)+ )+nipalsOneComponent cfg x y =+ let -- 初期 u: Y の最初の列+ u0 = LA.flatten (y LA.¿ [0])+ (uFinal, _iter) = iterate' cfg x y u0 0+ -- 最終 w 計算 (deflate 前の x, y で)+ xtu = LA.tr x LA.#> uFinal+ normXtu = sqrt (LA.sumElements (xtu * xtu))+ w = if normXtu > 1e-12 then LA.scale (1 / normXtu) xtu+ else xtu+ t = x LA.#> w+ tt = LA.sumElements (t * t)+ qy = if tt > 1e-12 then LA.scale (1 / tt) (LA.tr y LA.#> t)+ else LA.tr y LA.#> t+ pload = if tt > 1e-12 then LA.scale (1 / tt) (LA.tr x LA.#> t)+ else LA.tr x LA.#> t+ in (w, t, pload, qy)++-- | [日本語]: NIPALS の収束反復。 u を更新し続け、 |u_new - u| < tol で終了。+-- [English]: The NIPALS convergence iteration. Keeps updating u, and+-- terminates when |u_new - u| < tol.+iterate'+ :: PLSConfig+ -> LA.Matrix Double+ -> LA.Matrix Double+ -> LA.Vector Double -- u+ -> Int -- iter count+ -> (LA.Vector Double, Int)+iterate' cfg x y u !i+ | i >= plsMaxIter cfg = (u, i)+ | otherwise =+ let xtu = LA.tr x LA.#> u+ normXtu = sqrt (LA.sumElements (xtu * xtu))+ w = if normXtu > 1e-12 then LA.scale (1 / normXtu) xtu else xtu+ t = x LA.#> w+ tt = LA.sumElements (t * t)+ ytt = LA.tr y LA.#> t+ q = if tt > 1e-12 then LA.scale (1 / tt) ytt else ytt+ fq = y LA.#> q+ normFq = sqrt (LA.sumElements (fq * fq))+ uNew = if normFq > 1e-12 then LA.scale (1 / normFq) fq else fq+ diff = uNew - u+ err = sqrt (LA.sumElements (diff * diff))+ in if err < plsTol cfg+ then (uNew, i + 1)+ else iterate' cfg x y uNew (i + 1)++-- ===========================================================================+-- CV による component 数選択+-- ===========================================================================++data PLSLambdaSelection = PLSLambdaSelection+ { plsBestK :: !Int+ , plsCVMSEs :: ![Double]+ , plsCVSDs :: ![Double]+ , plsOneSeK :: !Int+ } deriving (Show)++-- | [日本語]: k-fold CV で component 数を 1..maxK の中から選ぶ。+-- [English]: Selects the number of components from 1..maxK via k-fold CV.+selectPLSComponentsCV+ :: Int -- ^ [日本語]: k-fold の k。 [English]: The k in k-fold.+ -> Int -- ^ [日本語]: maxK (component 数上限)。 [English]: maxK (the upper bound on the number of components).+ -> LA.Matrix Double -- ^ [日本語]: X。 [English]: X.+ -> LA.Matrix Double -- ^ [日本語]: Y。 [English]: Y.+ -> MWC.GenIO+ -> IO PLSLambdaSelection+selectPLSComponentsCV kFold maxK xMat yMat gen = do+ let n = LA.rows xMat+ folds <- HCV.kFold kFold n gen+ let perK kk =+ let cfg = defaultPLS { plsN_Components = kk }+ scores =+ [ mseForFold cfg xMat yMat trainIdx testIdx+ | (trainIdx, testIdx) <- folds, not (null testIdx)+ ]+ !nFolds = fromIntegral (length scores) :: Double+ meanMSE = sum scores / nFolds+ varN = sum [(s - meanMSE) ** 2 | s <- scores] / max 1 (nFolds - 1)+ !se = sqrt (varN / nFolds)+ in (meanMSE, se)+ ks = [1 .. maxK]+ stats = map perK ks+ mses = map fst stats+ ses = map snd stats+ indexedMSEs = zip3 ks mses ses+ sortedAsc = sortBy (comparing (\(_, m, _) -> m)) indexedMSEs+ (bestK_, bestMSE, bestSE) =+ case sortedAsc of+ (h:_) -> h+ [] -> (1, 0, 0)+ threshold = bestMSE + bestSE+ -- 1-SE rule: 最も sparse な K (= 最小 K) で best MSE + 1·SE 以内+ oneSe = case [k | (k, m, _) <- indexedMSEs, m <= threshold] of+ [] -> bestK_+ xs -> minimum xs+ pure PLSLambdaSelection+ { plsBestK = bestK_+ , plsCVMSEs = mses+ , plsCVSDs = ses+ , plsOneSeK = oneSe+ }++mseForFold+ :: PLSConfig+ -> LA.Matrix Double+ -> LA.Matrix Double+ -> [Int]+ -> [Int]+ -> Double+mseForFold cfg xMat yMat trainIdx testIdx =+ let xTr = xMat LA.? trainIdx+ yTr = yMat LA.? trainIdx+ xTe = xMat LA.? testIdx+ yTe = yMat LA.? testIdx+ in case fitPLS cfg xTr yTr of+ Left _ -> 1/0+ Right fit ->+ let yHat = predictPLS fit xTe+ resid = yTe - yHat+ nTe = fromIntegral (length testIdx) :: Double+ in LA.sumElements (resid * resid) / nTe
+ src/Hanalyze/Model/PartialDependence.hs view
@@ -0,0 +1,157 @@+{-# LANGUAGE OverloadedStrings #-}+-- |+-- Module : Hanalyze.Model.PartialDependence+-- Description : 任意モデル対応の Partial Dependence / ICE 純粋計算エンジン (model 非依存・非ゲート層)+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- [日本語]: 部分従属 (Partial Dependence) / ICE の純粋計算エンジン — 任意モデル対応。+--+-- R @pdp::partial@ / sklearn @sklearn.inspection.partial_dependence@ 相当。 学習済モデルの+-- predict を「注目特徴を grid で振り、 他の特徴は訓練データの観測分布のまま」評価し、 全観測+-- 行で平均したものが PDP、 行ごとの曲線が ICE (individual conditional expectation)。+--+-- model 非依存 (predict 閉包のみを受ける) ゆえ __非ゲート層__ に置き、 図化は+-- 別パッケージ @hanalyze-plot@ の 'Hanalyze.Plot.ML'+-- (@cabal build --project-file=cabal.project.plot@ で build) が担う。+--+-- @+-- import Hanalyze.Model.PartialDependence+--+-- -- 任意モデルの predict 閉包を渡す (R pdp の pred.fun 流)。+-- let r = partialDependence trainX (\\m -> map (predictRF rf) (LA.toLists m)) 0 40+-- in (pdpGrid r, pdpMean r) -- 特徴 0 の PDP 曲線+-- @+--+-- [English]: A pure computation engine for Partial Dependence / ICE —+-- supports arbitrary models.+--+-- Equivalent to R's @pdp::partial@ \/ sklearn's+-- @sklearn.inspection.partial_dependence@. Evaluates a fitted model's+-- predict by "sweeping the feature of interest over a grid while+-- keeping the other features at their observed distribution in the+-- training data"; the average over all observation rows is the PDP,+-- and the per-row curves are the ICE (individual conditional+-- expectation).+--+-- Since it is model-independent (it only takes a predict closure), it+-- lives in the __non-gated layer__; visualization is handled by+-- 'Hanalyze.Plot.ML' in the separate @hanalyze-plot@+-- package (built via @cabal build --project-file=cabal.project.plot@).+--+-- @+-- import Hanalyze.Model.PartialDependence+--+-- -- Pass an arbitrary model's predict closure (in the style of R+-- -- pdp's pred.fun).+-- let r = partialDependence trainX (\\m -> map (predictRF rf) (LA.toLists m)) 0 40+-- in (pdpGrid r, pdpMean r) -- The PDP curve for feature 0+-- @+module Hanalyze.Model.PartialDependence+ ( -- * 結果型+ PDPResult (..)+ -- * 計算+ , partialDependence+ , partialDependenceGrid+ -- * 変換+ , centerICE+ ) where++import qualified Numeric.LinearAlgebra as LA+import Data.List (transpose)++-- ===========================================================================+-- 結果型+-- ===========================================================================++-- | [日本語]: 部分従属の計算結果。 grid・PDP 平均曲線・ICE 個体曲線群をまとめて返す。+-- [English]: The result of a partial-dependence computation. Returns+-- the grid, the PDP mean curve, and the group of ICE individual+-- curves together.+data PDPResult = PDPResult+ { pdpGrid :: ![Double] -- ^ [日本語]: 注目特徴の grid 値 (長さ = grid 数)。 [English]: The grid values for the feature of interest (length = grid count).+ , pdpMean :: ![Double] -- ^ [日本語]: PDP: 各 grid 値で全観測行の予測を平均 (長さ = grid 数)。 [English]: PDP: the average prediction across all observation rows at each grid value (length = grid count).+ , pdpIce :: ![[Double]] -- ^ [日本語]: ICE: 観測行ごとの曲線 (n 本・各長さ = grid 数)。 [English]: ICE: one curve per observation row (n curves, each of length = grid count).+ } deriving (Eq, Show)++-- ===========================================================================+-- 計算+-- ===========================================================================++-- | [日本語]: 注目特徴 j の観測 @[min,max]@ を等間隔 grid にして PDP/ICE を計算する。+-- grid 数 <2 は 2 に切り上げ。 空データ・列外 index は空結果 ('PDPResult' [] [] [])。+-- [English]: Computes PDP\/ICE by turning the observed @[min,max]@ of+-- feature j into an evenly-spaced grid. A grid count <2 is rounded up+-- to 2. Empty data or an out-of-range column index yields an empty+-- result ('PDPResult' [] [] []).+partialDependence+ :: LA.Matrix Double -- ^ [日本語]: 訓練特徴行列 X (n 行 × p 列)。 [English]: The training feature matrix X (n rows × p columns).+ -> (LA.Matrix Double -> [Double]) -- ^ [日本語]: predict: 行列の各行 → 予測値 (長さ = 行数)。 [English]: predict: each row of the matrix → a predicted value (length = row count).+ -> Int -- ^ [日本語]: 注目特徴の列 index j (0 始まり)。 [English]: The column index j of the feature of interest (0-based).+ -> Int -- ^ [日本語]: grid 数。 [English]: The grid count.+ -> PDPResult+partialDependence x predict j n+ | LA.rows x == 0 || j < 0 || j >= LA.cols x = PDPResult [] [] []+ | otherwise =+ let col = LA.toList (LA.toColumns x !! j)+ lo = minimum col+ hi = maximum col+ m = max 2 n+ grid = [ lo + (hi - lo) * fromIntegral i / fromIntegral (m - 1)+ | i <- [0 .. m - 1] ]+ in partialDependenceGrid x predict j grid++-- | [日本語]: grid を明示指定する版。 分位点 grid や任意評価点を渡したいときに使う。+-- 空 grid・空データ・列外 index は空結果。+-- [English]: The variant that explicitly specifies the grid. Use it+-- when passing a quantile grid or arbitrary evaluation points. An+-- empty grid, empty data, or an out-of-range column index yields an+-- empty result.+partialDependenceGrid+ :: LA.Matrix Double+ -> (LA.Matrix Double -> [Double])+ -> Int+ -> [Double] -- ^ [日本語]: 注目特徴の評価 grid。 [English]: The evaluation grid for the feature of interest.+ -> PDPResult+partialDependenceGrid x predict j grid+ | LA.rows x == 0 || j < 0 || j >= LA.cols x || null grid = PDPResult [] [] []+ | otherwise =+ let nrows = LA.rows x+ cols = LA.toColumns x+ -- 各 grid 値 g で X の j 列を定数 g に置換 → 全行 predict (長さ nrows)。+ predsAtG g =+ let xg = LA.fromColumns+ [ if c == j then LA.konst g nrows else col+ | (c, col) <- zip [0 ..] cols ]+ in predict xg+ byGrid = [ predsAtG g | g <- grid ] -- grid × n+ means = [ sum ps / fromIntegral nrows | ps <- byGrid ]+ ice = transpose byGrid -- n × grid (行ごとの曲線)+ in PDPResult grid means ice++-- ===========================================================================+-- 変換+-- ===========================================================================++-- | [日本語]: 中心化 ICE (c-ICE)。 各 ICE 曲線を __左端 (grid[0]) の値が 0__ に+-- なるよう平行移動し、 PDP 平均も中心化後の ICE から取り直す。 個体間の傾き差を+-- 見やすくする (sklearn @centered=True@ / R @ice()@ centered 相当)。 空結果はそのまま。+-- [English]: Centered ICE (c-ICE). Shifts each ICE curve so that+-- __the value at the left end (grid[0]) is 0__, and re-derives the+-- PDP mean from the centered ICE. Makes differences in slope between+-- individuals easier to see (equivalent to sklearn's+-- @centered=True@ \/ R's @ice()@ centered). An empty result passes+-- through unchanged.+centerICE :: PDPResult -> PDPResult+centerICE r+ | null (pdpGrid r) || null (pdpIce r) = r+ | otherwise =+ let ice' = [ case curve of+ (c0 : _) -> map (subtract c0) curve+ [] -> curve+ | curve <- pdpIce r ]+ nrows = length ice'+ means' = case ice' of+ [] -> []+ _ -> map (\col -> sum col / fromIntegral nrows) (transpose ice')+ in r { pdpMean = means', pdpIce = ice' }
+ src/Hanalyze/Model/Quantile.hs view
@@ -0,0 +1,184 @@+{-# LANGUAGE OverloadedStrings #-}+-- |+-- Module : Hanalyze.Model.Quantile+-- Description : Quantile regression — Hunter & Lange (2000) MM 法による条件付き τ-分位点回帰+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- Quantile regression.+--+-- Whereas OLS fits the conditional /mean/, quantile regression fits the+-- conditional @τ@-quantile (with @τ ∈ (0, 1)@). @τ = 0.5@ gives outlier-+-- robust median regression; @τ = 0.1 / 0.9@ estimate lower / upper+-- quantiles, useful for predictive intervals and heteroscedastic data.+--+-- Loss function (pinball / check loss):+--+-- > ρ_τ(u) = u (τ - 𝟙[u < 0]) = τ u if u ≥ 0+-- > (τ-1) u if u < 0+--+-- Algorithm: Hunter & Lange (2000) Majorization-Minimization. Locally+-- approximate @|u|@ by a quadratic and iterate weighted least squares:+--+-- [日本語]:+--+-- 1. β₀ = OLS 解で初期化+-- 2. 反復 k:+-- - r = y - X β_k+-- - w_i = 1 / (2 max(|r_i|, ε))+-- - y'_i = y_i + (τ - ½) / w_i+-- - β_{k+1} = (Xᵀ W X)⁻¹ Xᵀ W y'+-- 3. ||β_{k+1} - β_k|| < tol で停止 (max 100 iter)。+--+-- 評価指標 (Koenker-Machado 1999): R¹_τ = 1 - V̂_τ(model) / V̂_τ(intercept-only)+-- where V̂_τ(m) = Σ ρ_τ(r_i^m)。+--+-- [English]:+--+-- 1. Initialize β₀ with the OLS solution.+-- 2. Iteration k:+-- - r = y - X β_k+-- - w_i = 1 / (2 max(|r_i|, ε))+-- - y'_i = y_i + (τ - ½) / w_i+-- - β_{k+1} = (Xᵀ W X)⁻¹ Xᵀ W y'+-- 3. Stop when ||β_{k+1} - β_k|| < tol (max 100 iterations).+--+-- Evaluation metric (Koenker-Machado 1999): R¹_τ = 1 - V̂_τ(model) / V̂_τ(intercept-only)+-- where V̂_τ(m) = Σ ρ_τ(r_i^m).+module Hanalyze.Model.Quantile+ ( QRFit (..)+ , fitQuantile+ , predictQuantile+ , pinballLoss+ , pseudoR1+ ) where++import qualified Data.List as L+import qualified Numeric.LinearAlgebra as LA+import qualified Hanalyze.Stat.Cholesky as Chol++-- ---------------------------------------------------------------------------+-- 型+-- ---------------------------------------------------------------------------++-- | Quantile-regression fit result.+data QRFit = QRFit+ { qfTau :: Double -- ^ Quantile level @τ ∈ (0, 1)@.+ , qfBeta :: LA.Vector Double -- ^ Coefficients.+ , qfYHat :: LA.Vector Double -- ^ Fitted values @X β@.+ , qfResid :: LA.Vector Double -- ^ Residuals @y − X β@.+ , qfPinball :: Double -- ^ Total pinball loss @V̂_τ@.+ , qfR1 :: Double -- ^ Koenker-Machado pseudo @R¹_τ@.+ , qfIters :: Int -- ^ Number of iterations executed.+ } deriving (Show)++-- ---------------------------------------------------------------------------+-- フィット+-- ---------------------------------------------------------------------------++-- | Fit a @τ@-quantile regression by Majorization-Minimization IRLS.+fitQuantile :: Double -- ^ Quantile level @τ ∈ (0, 1)@.+ -> LA.Matrix Double -- ^ Design matrix @X@ (must include the intercept column).+ -> LA.Vector Double -- ^ Response @y@.+ -> QRFit+fitQuantile tau x y+ | tau <= 0 || tau >= 1 = error "fitQuantile: tau must be in (0, 1)"+ | otherwise =+ let !beta0 = x LA.<\> y -- OLS 初期値+ !eps = 1e-6+ !maxIter = 100 :: Int+ !tol = 1e-7+ !p = LA.cols x+ !onesP = LA.konst 1 p :: LA.Vector Double+ (betaF, k) = loop beta0 0+ loop b iter+ | iter >= maxIter = (b, iter)+ | otherwise =+ let !r = y - x LA.#> b+ -- w_i = 1 / (2 max(|r_i|, eps))+ !wVec = LA.cmap (\v -> 1 / (2 * max eps (abs v))) r+ -- y' = y + (tau - 0.5) / w+ !yp = y + LA.cmap (\wi -> (tau - 0.5) / wi) wVec+ -- W^{1/2}.+ !sqW = LA.cmap sqrt wVec+ -- B10a (2026-05-06): row-scaling of X via outer+ -- product (broadcast sqW across columns) instead+ -- of the previous "@LA.toRows x !! i@" + "@diag@"+ -- combination, which was @O(n² p)@ per iteration+ -- (76× slower than statsmodels on n=10k p=20).+ -- Now @O(n p)@ per iteration — single elementwise+ -- multiply with a fully-allocated outer product.+ !sqWBcast = LA.outer sqW onesP -- n × p+ !xScaled = sqWBcast * x -- n × p+ !yScaled = sqW * yp -- length n+ -- Solve the SPD normal equations+ -- (X^T W X) β = X^T W y'+ -- via Cholesky rather than the general LSQ path+ -- '@LA.<\>@' (QR/dgels). For @p ≪ n@ the @p × p@+ -- @aMat@ is tiny and dpotrf is faster than dgels+ -- on the @n × p@ @xScaled@ matrix; this is the+ -- same trick GLM IRLS already uses.+ !aMat = LA.tr xScaled LA.<> xScaled+ !rhs = LA.asColumn (LA.tr xScaled LA.#> yScaled)+ !bNew = LA.flatten (Chol.cholSolveJitter aMat rhs)+ !delta = LA.norm_2 (bNew - b)+ in if delta < tol then (bNew, iter + 1)+ else loop bNew (iter + 1)+ yhat = x LA.#> betaF+ resid = y - yhat+ loss = pinballLoss tau (LA.toList resid)+ -- baseline: intercept-only model with τ-quantile of y+ ys = LA.toList y+ baseQ = quantile tau ys+ baseR = [ yi - baseQ | yi <- ys ]+ baseLoss = pinballLoss tau baseR+ r1 = if baseLoss <= 1e-12 then 0+ else 1 - loss / baseLoss+ in QRFit+ { qfTau = tau+ , qfBeta = betaF+ , qfYHat = yhat+ , qfResid = resid+ , qfPinball = loss+ , qfR1 = r1+ , qfIters = k+ }++-- | Predict at new inputs.+predictQuantile :: QRFit -> LA.Matrix Double -> LA.Vector Double+predictQuantile fit xNew = xNew LA.#> qfBeta fit++-- ---------------------------------------------------------------------------+-- 補助関数+-- ---------------------------------------------------------------------------++-- | Total pinball / check loss: @Σ ρ_τ(r_i)@.+pinballLoss :: Double -> [Double] -> Double+pinballLoss tau rs =+ sum [ if r >= 0 then tau * r else (tau - 1) * r | r <- rs ]++-- | Empirical @τ@-quantile (simple linear-interpolation style).+quantile :: Double -> [Double] -> Double+quantile p xs+ | null xs = 0+ | otherwise =+ -- Phase 11b (2026-05-14): replaced naive list quicksort with+ -- 'Data.List.sort' (mergesort, O(n log n), O(n) space). Pivot-bias+ -- could push the old version to O(n²) space.+ let sorted = L.sort xs+ n = length sorted+ ix = p * fromIntegral (n - 1)+ lo = floor ix :: Int+ hi = min (n - 1) (lo + 1)+ frac = ix - fromIntegral lo+ in (1 - frac) * (sorted !! lo) + frac * (sorted !! hi)++-- | [日本語]: Pseudo R¹_τ を別途計算 (model loss と baseline loss から)。+-- [English]: Compute the pseudo R¹_τ separately (from the model loss+-- and baseline loss).+pseudoR1 :: Double -- ^ model V̂_τ+ -> Double -- ^ baseline (intercept-only) V̂_τ+ -> Double+pseudoR1 modelV baseV+ | baseV <= 1e-12 = 0+ | otherwise = 1 - modelV / baseV
+ src/Hanalyze/Model/RFF.hs view
@@ -0,0 +1,1193 @@+{-# LANGUAGE StrictData #-}+{-# LANGUAGE OverloadedStrings #-}+-- |+-- Module : Hanalyze.Model.RFF+-- Description : Random Fourier Features (RFF) — Bochner の定理に基づく kernel の明示的特徴写像近似+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- [日本語]: Random Fourier Features (RFF) — kernel 近似。+--+-- Bochner の定理により、 定常カーネル+-- @k(x, x') = ∫ p(ω) e^{iω(x-x')} dω@ は、 @p(ω)@ からサンプリングした @D@ 個の+-- 周波数 @ω_j@ と一様位相 @b_j@ を介した明示的な特徴写像を持つ:+--+-- @+-- φ(x) = σ_f √(2/D) [cos(ω_j x + b_j)]_{j=1..D}+-- @+--+-- これにより @k(x, x') ≈ φ(x)·φ(x')@ となる (Rahimi & Recht 2007)。+--+-- 利点:+--+-- - @O(n³)@ の kernel 計算が @O(n D + D³)@ に削減される — @n@ について線形。+-- - Ridge 回帰と GP 事後分布が @D@ 次元の線形代数になる。+--+-- 本モジュールは単変量・多変量入力の両方 (@MV@ 接尾辞の API) をサポートする。+-- - 'sampleRFFRBF': RBF カーネル (ω ~ N(0, 1/ℓ²))+-- - 'sampleRFFMatern52': Matérn 5/2 (ω ~ scaled t with df = 5)+-- - 'rffFeatures': 特徴行列 Φ を構築 (n × D)+-- - 'rffRidge': RFF + Ridge 回帰 (=O(n³) Kernel Ridge の近似)+-- - 'rffGP': RFF + ベイズ線形回帰 = GP 事後の近似 (mean + variance)+--+-- [English]: Random Fourier Features (RFF) — kernel approximation.+--+-- By Bochner's theorem, a stationary kernel+-- @k(x, x') = ∫ p(ω) e^{iω(x-x')} dω@ admits an explicit feature map+-- defined via @D@ frequencies @ω_j@ sampled from @p(ω)@ and uniform+-- phases @b_j@:+--+-- @+-- φ(x) = σ_f √(2/D) [cos(ω_j x + b_j)]_{j=1..D}+-- @+--+-- so that @k(x, x') ≈ φ(x)·φ(x')@ (Rahimi & Recht 2007).+--+-- Benefits:+--+-- - @O(n³)@ kernel computation reduces to @O(n D + D³)@ — linear in @n@.+-- - Ridge regression and GP posterior become @D@-dimensional linear+-- algebra.+--+-- This module supports both univariate and multivariate inputs (the+-- @MV@-suffixed APIs).+-- - 'sampleRFFRBF': RBF kernel (ω ~ N(0, 1/ℓ²))+-- - 'sampleRFFMatern52': Matérn 5/2 (ω ~ scaled t with df = 5)+-- - 'rffFeatures': builds the feature matrix Φ (n × D)+-- - 'rffRidge': RFF + Ridge regression (= approximation of O(n³) Kernel Ridge)+-- - 'rffGP': RFF + Bayesian linear regression = approximation of the GP posterior (mean + variance)+module Hanalyze.Model.RFF+ ( RFFKernel (..)+ , RFFFeatures (..)+ , rffDim+ -- * Feature generation+ , sampleRFFRBF+ , sampleRFFMatern52+ , sampleRFFRBFPure+ , sampleRFFMatern52Pure+ , rffFeatures+ , rffApproxKernel+ -- * RFF ridge regression (primary API: multi-output)+ , RFFRidgeFit (..)+ , rffRidge+ , predictRFFRidge+ , RFFRidgeFitMulti (..)+ , rffRidgeMulti+ , predictRFFRidgeMulti+ -- * RFF GP (posterior mean + variance)+ , RFFGPFit (..)+ , rffGP+ , predictRFFGP+ -- * Multivariate input (@p@ dimensions)+ , RFFFeaturesMV (..)+ , sampleRFFRBFMV+ , sampleRFFMatern52MV+ , sampleRFFRBFMVPure+ , sampleRFFMatern52MVPure+ , rffFeaturesMV+ , RFFRidgeFitMV (..)+ , rffRidgeMV+ , predictRFFRidgeMV+ , RFFGPFitMV (..)+ , rffGPMV+ , predictRFFGPMV+ , RFFRidgeFitMVMO (..)+ , rffRidgeMVMulti+ , predictRFFRidgeMVMulti+ -- * Marginal-likelihood maximization (auto-tune ℓ, σ_f, σ_n)+ , logMarginalLikRBFMV+ , maximizeMarginalLikRBFMV+ , maximizeMarginalLikRBFMV_DE+ , MLikResult (..)+ -- * LOOCV closed form (faster HP auto-tuning)+ , loocvFromPhi+ , loocvRFFRidgeMV+ , gridSearchLOOCVRBFMV+ , gridSearchLOOCVRBFMV_DE+ , bayesOptLOOCVRBFMV+ , lbfgsLOOCVRBFMV+ , LOOCVResult (..)+ ) where++import Control.Exception (SomeException, try, evaluate)+import Control.Monad.Primitive (PrimMonad, PrimState)+import Data.Word (Word32)+import qualified Data.Vector as V+import qualified Data.Vector.Storable as VS+import qualified Data.Vector.Storable.Mutable as VSM+import Control.Monad.ST (runST)+import qualified Numeric.LinearAlgebra as LA+import qualified System.IO.Unsafe+import System.IO.Unsafe (unsafePerformIO)+import qualified System.Random.MWC+import System.Random.MWC (GenIO, Gen, uniformR, initialize)+import qualified System.Random.MWC.Distributions as MWCD+import qualified Hanalyze.Optim.DifferentialEvolution as DEM+import qualified Hanalyze.Optim.Common as OCM+import qualified Hanalyze.Optim.BayesOpt as BO+import qualified Hanalyze.Optim.LBFGS as LBFGS+import qualified Hanalyze.Stat.Cholesky as Chol+import qualified Hanalyze.Stat.KernelDist as KD+import qualified Data.Vector.Algorithms.Intro as Intro++-- ---------------------------------------------------------------------------+-- 型+-- ---------------------------------------------------------------------------++-- | Supported kernels for RFF approximation.+data RFFKernel = RFFRBF | RFFMatern52+ deriving (Show, Eq)++-- | All the information needed to evaluate an RFF feature map.+data RFFFeatures = RFFFeatures+ { rffKernel :: RFFKernel+ , rffOmegas :: V.Vector Double -- ^ Random frequencies @ω_j@ (length @D@).+ , rffBs :: V.Vector Double -- ^ Random phases @b_j ∈ [0, 2π)@.+ , rffSigmaF :: Double -- ^ Signal standard deviation @σ_f@.+ , rffLengthScale :: Double -- ^ Length scale @ℓ@.+ } deriving (Show)++-- | Number of features @D@.+rffDim :: RFFFeatures -> Int+rffDim = V.length . rffOmegas++-- ---------------------------------------------------------------------------+-- 周波数サンプリング+-- ---------------------------------------------------------------------------++-- | [日本語]: RBF カーネル用の RFF 特徴をサンプリングする:+-- @ω_j ~ N(0, 1/ℓ²)@、 @b_j ~ U(0, 2π)@。+--+-- 'PrimMonad' 汎用 (mwc は 'PrimMonad' 汎用ゆえ ST/IO 両経路で同コード)。+-- IO 呼び出しは @GenIO = Gen (PrimState IO)@ ゆえ従来どおり。 純粋 (seed) 経路は+-- 'sampleRFFRBFPure' (@gp@ spec の RFF 近似象限を pure @fitWith@ で完結+-- させるため・[[kMeansPure]]/[[fitRFVPure]] と一貫)。+-- [English]: Sample RFF features for the RBF kernel: @ω_j ~ N(0, 1/ℓ²)@,+-- @b_j ~ U(0, 2π)@.+--+-- Generic over 'PrimMonad' (mwc is generic over 'PrimMonad', so the same+-- code serves both the ST and IO paths). IO calls use+-- @GenIO = Gen (PrimState IO)@ as before. The pure (seed) path is+-- 'sampleRFFRBFPure' (a bridge letting the @gp@ spec's RFF-approximation+-- quadrant be completed with a pure @fitWith@, consistent with+-- [[kMeansPure]]\/[[fitRFVPure]]).+sampleRFFRBF :: PrimMonad m+ => Int -- ^ Feature dimension @D@.+ -> Double -- ^ Length scale @ℓ@.+ -> Double -- ^ Signal SD @σ_f@.+ -> Gen (PrimState m) -> m RFFFeatures+sampleRFFRBF d ell sf gen = do+ ws <- V.replicateM d (MWCD.normal 0 (1/ell) gen)+ bs <- V.replicateM d (uniformR (0, 2*pi) gen)+ return RFFFeatures+ { rffKernel = RFFRBF+ , rffOmegas = ws+ , rffBs = bs+ , rffSigmaF = sf+ , rffLengthScale = ell+ }++-- | Sample RFF features for the Matérn 5/2 kernel:+-- @ω = z/√u@ where @z ~ N(0, 1/ℓ²)@ and @u ~ Gamma(ν, ν)@ with @ν = 5/2@.+-- This is a scaled @df = 5@ Student-t distribution, matching the+-- spectral density.+sampleRFFMatern52 :: PrimMonad m+ => Int -> Double -> Double -> Gen (PrimState m) -> m RFFFeatures+sampleRFFMatern52 d ell sf gen = do+ let nu = 2.5 :: Double+ ws <- V.replicateM d $ do+ z <- MWCD.normal 0 (1/ell) gen+ -- mwc-random-distributions の gamma は (shape, scale) 渡し → mean = shape * scale+ -- Gamma(ν, 1/ν) で mean = 1+ u <- MWCD.gamma nu (1/nu) gen+ return (z / sqrt u)+ bs <- V.replicateM d (uniformR (0, 2*pi) gen)+ return RFFFeatures+ { rffKernel = RFFMatern52+ , rffOmegas = ws+ , rffBs = bs+ , rffSigmaF = sf+ , rffLengthScale = ell+ }++-- | [日本語]: 純粋 (seed) 版 'sampleRFFRBF'。 同 seed → 同 'RFFFeatures'+-- (ST\/IO ビット一致)。 @gp@ spec の @GpRff@\/@RidgeRff@ 象限を pure+-- @fitWith@ で完結させる継ぎ目。+-- [English]: Pure (seed) version of 'sampleRFFRBF'. The same seed yields+-- the same 'RFFFeatures' (bit-identical across ST\/IO). The bridge that+-- lets the @gp@ spec's @GpRff@\/@RidgeRff@ quadrant be completed with a+-- pure @fitWith@.+sampleRFFRBFPure :: Int -> Double -> Double -> Word32 -> RFFFeatures+sampleRFFRBFPure d ell sf seed =+ runST (initialize (V.singleton seed) >>= sampleRFFRBF d ell sf)++-- | [日本語]: 純粋 (seed) 版 'sampleRFFMatern52'。+-- [English]: Pure (seed) version of 'sampleRFFMatern52'.+sampleRFFMatern52Pure :: Int -> Double -> Double -> Word32 -> RFFFeatures+sampleRFFMatern52Pure d ell sf seed =+ runST (initialize (V.singleton seed) >>= sampleRFFMatern52 d ell sf)++-- ---------------------------------------------------------------------------+-- 特徴写像+-- ---------------------------------------------------------------------------++-- | Feature matrix @Φ ∈ ℝ^{n×D}@.+-- @φ(x) = σ_f √(2/D) [cos(ω_j x + b_j)]_{j=1..D}@.+--+-- Single-pass 'runST' implementation: avoids the @[Double]@+-- list-comprehension @(n × D)@ + 'LA.fromList' round-trip the+-- previous version performed.+rffFeatures :: RFFFeatures -> [Double] -> LA.Matrix Double+rffFeatures rff xs =+ let d = rffDim rff+ sf = rffSigmaF rff+ coef = sf * sqrt (2 / fromIntegral d)+ -- Convert input list / boxed Vectors to Storable for fast access.+ xsV = VS.fromList xs+ n = VS.length xsV+ ws = VS.fromList (V.toList (rffOmegas rff))+ bs = VS.fromList (V.toList (rffBs rff))+ out = runST $ do+ v <- VSM.new (n * d)+ let go i j+ | i >= n = pure ()+ | j >= d = go (i + 1) 0+ | otherwise = do+ let !x_ = xsV `VS.unsafeIndex` i+ !w_ = ws `VS.unsafeIndex` j+ !b_ = bs `VS.unsafeIndex` j+ !val = coef * cos (w_ * x_ + b_)+ VSM.unsafeWrite v (i * d + j) val+ go i (j + 1)+ go 0 0+ VS.unsafeFreeze v+ in LA.reshape d out++-- | Kernel matrix approximated by RFF: @K[i,j] ≈ k(x_i, x_j) = φ(x_i)·φ(x_j)@.+rffApproxKernel :: RFFFeatures -> [Double] -> LA.Matrix Double+rffApproxKernel rff xs =+ let phi = rffFeatures rff xs+ in phi LA.<> LA.tr phi++-- ---------------------------------------------------------------------------+-- RFF Ridge 回帰+-- ---------------------------------------------------------------------------++-- | Single-output RFF ridge fit.+data RFFRidgeFit = RFFRidgeFit+ { rffrFeatures :: RFFFeatures+ , rffrWeights :: LA.Vector Double -- ^ Weight vector (length @D@).+ , rffrLambda :: Double -- ^ Ridge penalty @λ@.+ } deriving (Show)++-- | Single-output RFF ridge regression. Delegates to 'rffRidgeMulti' by+-- promoting @y@ to a one-column matrix.+rffRidge :: RFFFeatures -> [Double] -> [Double] -> Double -> RFFRidgeFit+rffRidge rff xs ys lam =+ let yMat = LA.asColumn (LA.fromList ys)+ mf = rffRidgeMulti rff xs yMat lam+ w = LA.flatten (rffrmWeights mf LA.¿ [0])+ in RFFRidgeFit rff w lam++-- | Predict at new inputs from a 'RFFRidgeFit'.+predictRFFRidge :: RFFRidgeFit -> [Double] -> [Double]+predictRFFRidge fit xNew =+ let phi = rffFeatures (rffrFeatures fit) xNew+ yhat = phi LA.#> rffrWeights fit+ in LA.toList yhat++-- | Multi-output RFF ridge fit (1D inputs). @Y@ is @n × q@, weights @W@+-- are @D × q@.+data RFFRidgeFitMulti = RFFRidgeFitMulti+ { rffrmFeatures :: RFFFeatures+ , rffrmWeights :: LA.Matrix Double -- ^ Weight matrix (@D × q@).+ , rffrmLambda :: Double -- ^ Ridge penalty @λ@.+ } deriving (Show)++-- | Multi-output RFF ridge regression: @W = (ΦᵀΦ + λI)⁻¹ Φᵀ Y@.+-- SPD system; solved via Cholesky with diagonal regularizer applied+-- in place (@addToDiagRFF@).+rffRidgeMulti :: RFFFeatures -> [Double] -> LA.Matrix Double -> Double+ -> RFFRidgeFitMulti+rffRidgeMulti rff xs ys lam =+ let phi = rffFeatures rff xs -- n × D+ gram = LA.tr phi LA.<> phi -- D × D (SPD)+ regK = addToDiagRFF lam gram+ rhs = LA.tr phi LA.<> ys -- D × q+ w = Chol.cholSolveJitter regK rhs+ in RFFRidgeFitMulti rff w lam++-- | Multi-output prediction at new inputs from a 'RFFRidgeFitMulti'.+predictRFFRidgeMulti :: RFFRidgeFitMulti -> [Double] -> LA.Matrix Double+predictRFFRidgeMulti fit xNew =+ let phi = rffFeatures (rffrmFeatures fit) xNew+ in phi LA.<> rffrmWeights fit++-- ---------------------------------------------------------------------------+-- RFF GP (ベイズ線形回帰 with prior w ~ N(0, I))+-- ---------------------------------------------------------------------------++-- | Bayesian linear regression on RFF features (a Gaussian-process+-- approximation).+--+-- Prior: @w ~ N(0, I)@ (the @σ_f@ amplitude is already in the features).+--+-- Likelihood: @y = φᵀ w + ε@, @ε ~ N(0, σ_n²)@.+--+-- Posterior: @Σ⁻¹ = ΦᵀΦ / σ_n² + I@, @μ = Σ Φᵀ y / σ_n²@.+data RFFGPFit = RFFGPFit+ { rffgpFeatures :: RFFFeatures+ , rffgpSigma :: LA.Matrix Double -- ^ Posterior covariance @Σ@ (@D × D@).+ , rffgpMean :: LA.Vector Double -- ^ Posterior mean @μ@ (length @D@).+ , rffgpSigmaN :: Double -- ^ Observation noise SD @σ_n@.+ } deriving (Show)++-- | Fit an RFF-based Bayesian linear-regression GP.+rffGP :: RFFFeatures -> [Double] -> [Double] -> Double -> RFFGPFit+rffGP rff xs ys sigmaN =+ let phi = rffFeatures rff xs+ d = rffDim rff+ sigN2 = sigmaN ^ (2 :: Int)+ yV = LA.fromList ys+ sigInv = LA.scale (1 / sigN2) (LA.tr phi LA.<> phi)+ `LA.add` LA.ident d+ sigma = LA.inv sigInv+ mu = sigma LA.#> LA.scale (1 / sigN2) (LA.tr phi LA.#> yV)+ in RFFGPFit+ { rffgpFeatures = rff+ , rffgpSigma = sigma+ , rffgpMean = mu+ , rffgpSigmaN = sigmaN+ }++-- | Per-test-point @(mean, variance of f)@. The observation-noise term+-- @σ_n²@ is /not/ added.+--+-- @mean = φ(x*)ᵀ μ@, @var = φ(x*)ᵀ Σ φ(x*)@.+predictRFFGP :: RFFGPFit -> [Double] -> [(Double, Double)]+predictRFFGP fit xNew =+ let rff = rffgpFeatures fit+ phi = rffFeatures rff xNew -- n_new × D+ mu = rffgpMean fit+ sigma = rffgpSigma fit+ means = LA.toList (phi LA.#> mu)+ vars = [ max 0 (LA.dot phi_i (sigma LA.#> phi_i))+ | phi_i <- LA.toRows phi ]+ in zip means vars++-- ---------------------------------------------------------------------------+-- 多変量入力 (p 次元) 対応 (Phase B-RFF)+-- ---------------------------------------------------------------------------++-- | Multivariate RFF feature-generation parameters. 'rffmvOmegas' is a+-- @p × D@ matrix; each column is one frequency vector @ω_j ∈ ℝ^p@.+data RFFFeaturesMV = RFFFeaturesMV+ { rffmvKernel :: RFFKernel+ , rffmvDim :: Int -- ^ Input dimension @p@.+ , rffmvOmegas :: LA.Matrix Double -- ^ Frequencies (@p × D@).+ , rffmvBs :: V.Vector Double -- ^ Phases @b_j@ (length @D@).+ , rffmvSigmaF :: Double -- ^ Signal SD @σ_f@.+ , rffmvLengthScale :: Double -- ^ Shared length scale @ℓ@+ -- (no ARD support yet).+ } deriving (Show)++-- | Sample multivariate RFF features for the RBF kernel.+-- Each component @ω_j[k] ~ N(0, 1/ℓ²)@ independently.+sampleRFFRBFMV+ :: PrimMonad m+ => Int -> Int -> Double -> Double -> Gen (PrimState m) -> m RFFFeaturesMV+sampleRFFRBFMV p d ell sf gen = do+ let total = p * d+ ws <- V.replicateM total (MWCD.normal 0 (1/ell) gen)+ bs <- V.replicateM d (uniformR (0, 2*pi) gen)+ let omegaMat = LA.reshape d (LA.fromList (V.toList ws))+ return RFFFeaturesMV+ { rffmvKernel = RFFRBF+ , rffmvDim = p+ , rffmvOmegas = omegaMat+ , rffmvBs = bs+ , rffmvSigmaF = sf+ , rffmvLengthScale = ell+ }++-- | Sample multivariate RFF features for the Matérn 5/2 kernel.+sampleRFFMatern52MV+ :: PrimMonad m+ => Int -> Int -> Double -> Double -> Gen (PrimState m) -> m RFFFeaturesMV+sampleRFFMatern52MV p d ell sf gen = do+ let nu = 2.5 :: Double+ ws <- V.replicateM (p * d) $ do+ z <- MWCD.normal 0 (1/ell) gen+ u <- MWCD.gamma nu (1/nu) gen+ return (z / sqrt u)+ bs <- V.replicateM d (uniformR (0, 2*pi) gen)+ return RFFFeaturesMV+ { rffmvKernel = RFFMatern52+ , rffmvDim = p+ , rffmvOmegas = LA.reshape d (LA.fromList (V.toList ws))+ , rffmvBs = bs+ , rffmvSigmaF = sf+ , rffmvLengthScale = ell+ }++-- | [日本語]: 純粋 (seed) 版 'sampleRFFRBFMV'。+-- [English]: Pure (seed) version of 'sampleRFFRBFMV'.+sampleRFFRBFMVPure :: Int -> Int -> Double -> Double -> Word32 -> RFFFeaturesMV+sampleRFFRBFMVPure p d ell sf seed =+ runST (initialize (V.singleton seed) >>= sampleRFFRBFMV p d ell sf)++-- | [日本語]: 純粋 (seed) 版 'sampleRFFMatern52MV'。+-- [English]: Pure (seed) version of 'sampleRFFMatern52MV'.+sampleRFFMatern52MVPure :: Int -> Int -> Double -> Double -> Word32 -> RFFFeaturesMV+sampleRFFMatern52MVPure p d ell sf seed =+ runST (initialize (V.singleton seed) >>= sampleRFFMatern52MV p d ell sf)++-- | Multivariate feature matrix: @X (n × p) → Φ (n × D)@.+-- @φ_j(x) = σ_f √(2/D) cos(ω_jᵀ x + b_j)@.+--+-- Implementation: a single fused @runST + MVector@ pass writes the+-- @n × D@ output. The previous version went through+-- @LA.toRows xo + list comp (r + bs) + LA.fromRows + LA.cmap cos ++-- LA.scale coef@, allocating four @n × D@ intermediates and one list+-- of @n@ row vectors per call. This single-pass version emits one+-- @n × D@ allocation and computes+-- @coef · cos(xoFlat[i,j] + bs[j])@ in place.+rffFeaturesMV :: RFFFeaturesMV -> LA.Matrix Double -> LA.Matrix Double+rffFeaturesMV rff x =+ let d = LA.cols (rffmvOmegas rff)+ sf = rffmvSigmaF rff+ coef = sf * sqrt (2 / fromIntegral d)+ -- X @ Ω → n × D (BLAS GEMM, kept).+ xo = x LA.<> rffmvOmegas rff+ n = LA.rows xo+ xoFlat = LA.flatten xo+ -- Phases as a Storable Vector (length D) for O(1) indexing.+ bs = VS.fromList (V.toList (rffmvBs rff))+ out = runST $ do+ v <- VSM.new (n * d)+ let go i j+ | i >= n = pure ()+ | j >= d = go (i + 1) 0+ | otherwise = do+ let !idx = i * d + j+ !z = (xoFlat `VS.unsafeIndex` idx)+ + (bs `VS.unsafeIndex` j)+ !val = coef * cos z+ VSM.unsafeWrite v idx val+ go i (j + 1)+ go 0 0+ VS.unsafeFreeze v+ in LA.reshape d out++-- | Multivariate RFF ridge fit.+data RFFRidgeFitMV = RFFRidgeFitMV+ { rffrmvFeatures :: RFFFeaturesMV+ , rffrmvWeights :: LA.Vector Double -- ^ Weights (length @D@).+ , rffrmvLambda :: Double -- ^ Ridge penalty @λ@.+ } deriving (Show)++-- | Single-output multivariate RFF ridge regression. Delegates to+-- 'rffRidgeMVMulti' by promoting @y@ to a one-column matrix.+rffRidgeMV :: RFFFeaturesMV -> LA.Matrix Double -> [Double] -> Double+ -> RFFRidgeFitMV+rffRidgeMV rff x ys lam =+ let yMat = LA.asColumn (LA.fromList ys)+ mf = rffRidgeMVMulti rff x yMat lam+ w = LA.flatten (rffrmvmWeights mf LA.¿ [0])+ in RFFRidgeFitMV rff w lam++-- | Predict at new inputs from a 'RFFRidgeFitMV'.+predictRFFRidgeMV :: RFFRidgeFitMV -> LA.Matrix Double -> [Double]+predictRFFRidgeMV fit xNew =+ let phi = rffFeaturesMV (rffrmvFeatures fit) xNew+ in LA.toList (phi LA.#> rffrmvWeights fit)++-- | Multivariate-input RFF GP (Bayesian linear regression on RFF features).+-- The multi-input analogue of 'rffGP': same posterior algebra+-- (@Σ⁻¹ = ΦᵀΦ/σ_n² + I@, @μ = Σ Φᵀy/σ_n²@) but @Φ@ comes from+-- 'rffFeaturesMV'. Used by the @GpRff@ quadrant of the unified @gpMulti@+-- spec to provide a posterior-variance band under RFF approximation.+data RFFGPFitMV = RFFGPFitMV+ { rffgpmvFeatures :: RFFFeaturesMV+ , rffgpmvSigma :: LA.Matrix Double -- ^ Posterior covariance @Σ@ (@D × D@).+ , rffgpmvMean :: LA.Vector Double -- ^ Posterior mean @μ@ (length @D@).+ , rffgpmvSigmaN :: Double -- ^ Observation noise SD @σ_n@.+ } deriving (Show)++-- | Fit a multivariate-input RFF Bayesian-linear-regression GP.+rffGPMV :: RFFFeaturesMV -> LA.Matrix Double -> [Double] -> Double -> RFFGPFitMV+rffGPMV rff x ys sigmaN =+ let phi = rffFeaturesMV rff x+ d = LA.cols (rffmvOmegas rff)+ sigN2 = sigmaN ^ (2 :: Int)+ yV = LA.fromList ys+ sigInv = LA.scale (1 / sigN2) (LA.tr phi LA.<> phi) `LA.add` LA.ident d+ sigma = LA.inv sigInv+ mu = sigma LA.#> LA.scale (1 / sigN2) (LA.tr phi LA.#> yV)+ in RFFGPFitMV+ { rffgpmvFeatures = rff+ , rffgpmvSigma = sigma+ , rffgpmvMean = mu+ , rffgpmvSigmaN = sigmaN+ }++-- | Per-test-point @(mean, variance of f)@ for an 'RFFGPFitMV'. The+-- observation-noise term @σ_n²@ is /not/ added (matching 'predictRFFGP').+predictRFFGPMV :: RFFGPFitMV -> LA.Matrix Double -> [(Double, Double)]+predictRFFGPMV fit xNew =+ let phi = rffFeaturesMV (rffgpmvFeatures fit) xNew+ mu = rffgpmvMean fit+ sigma = rffgpmvSigma fit+ means = LA.toList (phi LA.#> mu)+ vars = [ max 0 (LA.dot p (sigma LA.#> p)) | p <- LA.toRows phi ]+ in zip means vars++-- | Multivariate-input multi-output RFF ridge fit. @X@ is @n × p@,+-- @Y@ is @n × q@, weights @W@ are @D × q@.+data RFFRidgeFitMVMO = RFFRidgeFitMVMO+ { rffrmvmFeatures :: RFFFeaturesMV+ , rffrmvmWeights :: LA.Matrix Double -- ^ D × q+ , rffrmvmLambda :: Double+ } deriving (Show)++-- | Multivariate-input multi-output RFF ridge regression:+-- @W = (ΦᵀΦ + λI)⁻¹ Φᵀ Y@.+--+-- The system is SPD by construction, so we solve via Cholesky rather+-- than the general LSQ path '(LA.<\>)'. The diagonal regularizer is+-- applied via @addToDiagRFF@ (in-place runST update) instead of+-- @gram + LA.scale lam (LA.ident d)@ which would allocate a fresh+-- @D × D@ identity.+rffRidgeMVMulti :: RFFFeaturesMV -> LA.Matrix Double -> LA.Matrix Double+ -> Double -> RFFRidgeFitMVMO+rffRidgeMVMulti rff x ys lam =+ let phi = rffFeaturesMV rff x -- n × D+ gram = LA.tr phi LA.<> phi -- D × D (SPD)+ regK = addToDiagRFF lam gram -- D × D+ rhs = LA.tr phi LA.<> ys -- D × q+ w = Chol.cholSolveJitter regK rhs+ in RFFRidgeFitMVMO rff w lam++-- | Add a scalar to the diagonal of a square matrix in a single+-- 'runST' pass (no fresh @D × D@ identity allocation). Mirrors+-- 'Hanalyze.Model.GP.addToDiag'; duplicated here to keep the modules+-- decoupled.+addToDiagRFF :: Double -> LA.Matrix Double -> LA.Matrix Double+addToDiagRFF c m =+ let d = LA.rows m+ flat = LA.flatten m+ out = runST $ do+ v <- VSM.new (d * d)+ let copy i+ | i >= d * d = pure ()+ | otherwise = do+ VSM.unsafeWrite v i (flat `VS.unsafeIndex` i)+ copy (i + 1)+ copy 0+ let bumpDiag i+ | i >= d = pure ()+ | otherwise = do+ let !idx = i * d + i+ d_old <- VSM.unsafeRead v idx+ VSM.unsafeWrite v idx (d_old + c)+ bumpDiag (i + 1)+ bumpDiag 0+ VS.unsafeFreeze v+ in LA.reshape d out++-- | Multi-output prediction at new inputs from a 'RFFRidgeFitMVMO'.+predictRFFRidgeMVMulti :: RFFRidgeFitMVMO -> LA.Matrix Double -> LA.Matrix Double+predictRFFRidgeMVMulti fit xNew =+ let phi = rffFeaturesMV (rffrmvmFeatures fit) xNew+ in phi LA.<> rffrmvmWeights fit++-- ---------------------------------------------------------------------------+-- 周辺尤度最大化 (RFF GP 流の HP チューニング、Phase 2)+-- ---------------------------------------------------------------------------++-- | [日本語]: 多変量入力 @X@ (@n × p@) と観測 @y@ に対する、 RBF カーネル下での+-- 周辺対数尤度。+--+-- K_ij = σ_f² · exp(-‖x_i - x_j‖² / (2 ℓ²))+-- y | θ ~ N(0, K + σ_n² I)+--+-- log p(y|θ) = -½ yᵀ (K+σ_n² I)⁻¹ y - ½ log|K+σ_n² I| - n/2 log(2π)+--+-- Cholesky 分解で安定計算。 ℓ が極小で K が特異化したら -∞ 近似値を返す。+-- [English]: Log marginal likelihood under the RBF kernel for multivariate+-- input @X@ (@n × p@) and observations @y@.+--+-- K_ij = σ_f² · exp(-‖x_i - x_j‖² / (2 ℓ²))+-- y | θ ~ N(0, K + σ_n² I)+--+-- log p(y|θ) = -½ yᵀ (K+σ_n² I)⁻¹ y - ½ log|K+σ_n² I| - n/2 log(2π)+--+-- Computed stably via Cholesky decomposition. If @ℓ@ is extremely small+-- and @K@ becomes singular, an approximate value of -∞ is returned.+logMarginalLikRBFMV+ :: LA.Matrix Double -- ^ X (n × p)+ -> LA.Vector Double -- ^ y (n)+ -> Double -- ^ ℓ+ -> Double -- ^ σ_f+ -> Double -- ^ σ_n+ -> Double+logMarginalLikRBFMV x y ell sf sn =+ let n = LA.rows x+ kMat = rbfKernelMat x ell sf+ cMat = kMat + LA.scale (sn * sn) (LA.ident n)+ -- Cholesky: cMat = Rᵀ R (R 上三角)。失敗時は jitter を加えて再試行。+ tryChol c =+ let result = unsafePerformIO $ try (evaluate (LA.chol (LA.sym c))) :: Either SomeException (LA.Matrix Double)+ in case result of+ Right r -> Just r+ Left _ -> Nothing+ mR = case tryChol cMat of+ Just r -> Just r+ Nothing -> tryChol (cMat + LA.scale 1e-6 (LA.ident n))+ in case mR of+ Nothing -> -1e30 -- 特異 → ペナルティ+ Just r ->+ let logDet = 2 * sum (map log (LA.toList (LA.takeDiag r)))+ alpha = cMat LA.<\> y+ dataFit = LA.dot y alpha+ in -0.5 * dataFit - 0.5 * logDet+ - fromIntegral n / 2 * log (2 * pi)++-- | RBF kernel matrix for inputs @X@ (@n × p@):+-- @K[i,j] = σ_f² · exp(−‖x_i − x_j‖² / (2ℓ²))@.+rbfKernelMat :: LA.Matrix Double -> Double -> Double -> LA.Matrix Double+rbfKernelMat x ell sf =+ let sf2 = sf * sf+ twol2 = 2 * ell * ell+ d2 = KD.pairwiseSqDist x+ in LA.cmap (\v -> sf2 * exp (negate v / twol2)) d2++-- | Marginal-likelihood maximization result.+data MLikResult = MLikResult+ { mlEll :: !Double+ , mlSigmaF :: !Double+ , mlSigmaN :: !Double+ , mlLogMlik :: !Double+ , mlGridPts :: !Int -- ^ [日本語]: 評価したグリッド点数 (debug 用)。 [English]: The number of grid points evaluated (for debugging).+ } deriving (Show)++-- | [日本語]: @(ℓ, σ_f, σ_n)@ に対するグリッドサーチで周辺尤度を最大化する。+--+-- 戦略:+--+-- 1. ℓ は median pairwise distance を中心に log 等間隔で n_ℓ 点+-- 2. σ_f は std(y) を中心に log で n_σf 点+-- 3. σ_n は std(y)·{0.001..0.5} の log 等間隔で n_σn 点+-- 4. 全 n_ℓ × n_σf × n_σn 点で log-mlik を評価し最良を取る+-- 5. 最良点周辺で 1/3 の幅で同点数のグリッドを再探索 (1 段の coarse-to-fine)+--+-- デフォルトは (20, 8, 8) = 1280 点。 最終的に 2560 点 (再探索込)。+-- n=200 までは数秒。+-- [English]: Maximize the marginal likelihood by grid search over+-- @(ℓ, σ_f, σ_n)@.+--+-- Strategy:+--+-- 1. @ℓ@: n_ℓ log-evenly-spaced points centered on the median pairwise+-- distance+-- 2. @σ_f@: n_σf log-spaced points centered on std(y)+-- 3. @σ_n@: n_σn log-evenly-spaced points over std(y)·{0.001..0.5}+-- 4. Evaluate log-mlik at all n_ℓ × n_σf × n_σn points and take the best+-- 5. Re-search a grid of the same size within 1\/3 of the width around+-- the best point (one stage of coarse-to-fine)+--+-- The default is (20, 8, 8) = 1280 points, 2560 points total (including+-- the re-search). Takes a few seconds up to n=200.+maximizeMarginalLikRBFMV+ :: LA.Matrix Double+ -> LA.Vector Double+ -> Maybe (Int, Int, Int) -- ^ (n_ℓ, n_σf, n_σn). Default (20,8,8)+ -> MLikResult+maximizeMarginalLikRBFMV x y mGrid =+ let (nL, nSF, nSN) = case mGrid of+ Just g -> g+ Nothing -> (20, 8, 8)+ yStd = sampleStd (LA.toList y)+ ellM = max 1e-3 (medianPairwiseDist x)+ sfM = max 1e-6 yStd+ -- Stage 1: 広めグリッド+ ellGrid1 = logSpace (ellM * 0.05) (ellM * 20) nL+ sfGrid1 = logSpace (sfM * 0.25) (sfM * 4) nSF+ snGrid1 = logSpace (yStd * 1e-3) (yStd * 0.5) nSN+ stage1 = bestOver x y ellGrid1 sfGrid1 snGrid1+ -- Stage 2: 最良点周辺で 1/3 幅+ (ell1, sf1, sn1, _) = stage1+ ellGrid2 = logSpace (ell1 / 3) (ell1 * 3) nL+ sfGrid2 = logSpace (sf1 / 2) (sf1 * 2) nSF+ snGrid2 = logSpace (sn1 / 3) (sn1 * 3) nSN+ stage2 = bestOver x y ellGrid2 sfGrid2 snGrid2+ (ell2, sf2, sn2, ml2) = stage2+ in MLikResult ell2 sf2 sn2 ml2+ (nL * nSF * nSN * 2)++-- | [日本語]: 'maximizeMarginalLikRBFMV' の Differential-Evolution 版。+--+-- coarse stage を Differential Evolution+-- (`Hanalyze.Optim.DifferentialEvolution`) で行い、 fine stage は+-- 従来通りグリッド。+--+-- DE の探索空間は log 空間 (log_ℓ, log_σ_f, log_σ_n) の 3 次元。 評価予算は+-- generations 引数で制御 (典型 30-100 で集団 30、 合計 900-3000 評価)。+-- グリッド版より広範囲を効率的に探索でき、 log-mlik の局所解にハマりにくい。+-- [English]: Differential-Evolution variant of 'maximizeMarginalLikRBFMV'.+--+-- The coarse stage uses Differential Evolution+-- (`Hanalyze.Optim.DifferentialEvolution`); the fine stage remains+-- grid-based as before.+--+-- DE's search space is the 3-dimensional log space (log_ℓ, log_σ_f,+-- log_σ_n). The evaluation budget is controlled by the generations+-- argument (typically 30-100 with a population of 30, for a total of+-- 900-3000 evaluations). It searches a wider range more efficiently than+-- the grid version and is less prone to getting stuck in local optima of+-- log-mlik.+maximizeMarginalLikRBFMV_DE+ :: LA.Matrix Double+ -> LA.Vector Double+ -> Int -- ^ DE generations+ -> System.Random.MWC.GenIO+ -> IO MLikResult+maximizeMarginalLikRBFMV_DE x y nGen gen = do+ let yStd = sampleStd (LA.toList y)+ ellM = max 1e-3 (medianPairwiseDist x)+ sfM = max 1e-6 yStd+ -- log 空間の bounds (元の logSpace 範囲と一致)+ bounds =+ [ (log (ellM * 0.05), log (ellM * 20)) -- log ℓ+ , (log (sfM * 0.25), log (sfM * 4)) -- log σ_f+ , (log (yStd * 1e-3), log (yStd * 0.5)) -- log σ_n+ ]+ -- 目的関数: log-mlik を最大化 → DE は最小化なので negate+ obj [le, lsf, lsn] = negate (logMarginalLikRBFMV x y (exp le) (exp lsf) (exp lsn))+ obj _ = 1e30+ let cfg = (DEM.defaultDEConfig bounds)+ { DEM.deStop = OCM.defaultStopCriteria { OCM.stMaxIter = nGen } }+ r <- DEM.runDEWith cfg obj gen+ let [le, lsf, lsn] = OCM.orBest r+ ell0 = exp le+ sf0 = exp lsf+ sn0 = exp lsn+ -- Stage 2 (fine grid) for refinement+ ellGrid2 = logSpace (ell0 / 3) (ell0 * 3) 8+ sfGrid2 = logSpace (sf0 / 2) (sf0 * 2) 6+ snGrid2 = logSpace (sn0 / 3) (sn0 * 3) 6+ (ell2, sf2, sn2, ml2) = bestOver x y ellGrid2 sfGrid2 snGrid2+ totalEvals = OCM.orIters r * DEM.dePopSize cfg + 8 * 6 * 6+ return $ MLikResult ell2 sf2 sn2 ml2 totalEvals++-- | Best @log p@ over the full Cartesian product of @(ellGrid, sfGrid, snGrid)@.+bestOver+ :: LA.Matrix Double -> LA.Vector Double+ -> [Double] -> [Double] -> [Double]+ -> (Double, Double, Double, Double)+bestOver x y ells sfs sns =+ let evaluations =+ [ (ell, sf, sn, logMarginalLikRBFMV x y ell sf sn)+ | ell <- ells, sf <- sfs, sn <- sns ]+ best = foldr1 (\a@(_,_,_,la) b@(_,_,_,lb) ->+ if la >= lb then a else b) evaluations+ in best++-- | Log-spaced @n@ points between @lo@ and @hi@.+logSpace :: Double -> Double -> Int -> [Double]+logSpace lo hi n+ | n <= 1 = [lo]+ | lo <= 0 = logSpace 1e-9 hi n -- 安全フォールバック+ | otherwise =+ let lLo = log lo+ lHi = log hi+ step = (lHi - lLo) / fromIntegral (n - 1)+ in [ exp (lLo + fromIntegral i * step) | i <- [0 .. n - 1] ]++-- | Median pairwise distance between rows (the standard median heuristic+-- for an RBF length scale).+-- | Rewritten to use BLAS gram matrix+-- ('KD.pairwiseSqDist') + 'Intro.sort' on a flat 'VS.Vector'. The previous+-- implementation built an @O(n²)@ list of pair distances with @rows !! i@+-- (each @O(i)@) and ran a naive list quicksort, which exploded space to+-- @O(n²)@..@O(n³)@ thunks and OOM-killed WSL2 around @n=768@.+medianPairwiseDist :: LA.Matrix Double -> Double+medianPairwiseDist x =+ let n = LA.rows x in+ if n < 2 then 1.0 else+ let d2 = KD.pairwiseSqDist x -- n × n via BLAS GEMM+ d2f = LA.flatten d2+ m = n * (n - 1) `div` 2+ ds = runST $ do+ v <- VSM.unsafeNew m+ let go !k !i !j+ | i >= n - 1 = pure ()+ | j >= n = go k (i + 1) (i + 2)+ | otherwise = do+ let s = VS.unsafeIndex d2f (i * n + j)+ VSM.unsafeWrite v k (sqrt (max 0 s))+ go (k + 1) i (j + 1)+ go 0 0 1+ Intro.sort v+ VS.unsafeFreeze v+ in if VS.null ds then 1.0 else VS.unsafeIndex ds (m `div` 2)++sampleStd :: [Double] -> Double+sampleStd xs+ | length xs <= 1 = 1.0+ | otherwise =+ let n = fromIntegral (length xs)+ m = sum xs / n+ v = sum [ (x - m) * (x - m) | x <- xs ] / (n - 1)+ in if v <= 0 then 1.0 else sqrt v+++-- ---------------------------------------------------------------------------+-- LOOCV 解析解 (Phase 3 — Ridge の closed-form leave-one-out cross-validation)+-- ---------------------------------------------------------------------------++-- | Result of LOOCV-based hyperparameter search.+data LOOCVResult = LOOCVResult+ { lcEll :: !Double+ , lcSigmaF :: !Double -- ^ [日本語]: 信号 sd (= std(y) を使う簡易版)。 [English]: Signal SD (a simplified version using std(y)).+ , lcLambda :: !Double -- ^ [日本語]: Ridge 正則化。 [English]: Ridge regularization.+ , lcLOOCV :: !Double -- ^ LOOCV(λ) = mean square LOO residual+ , lcGridPts :: !Int+ } deriving (Show)++-- | [日本語]: Cholesky 分解とハット行列の対角を使った、 RFF ridge 回帰の+-- closed-form LOOCV。+--+-- H = Φ (ΦᵀΦ + λI)⁻¹ Φᵀ+-- ŷ = H y+-- LOOCV(λ) = (1/n) Σᵢ ((y_i - ŷ_i) / (1 - H_ii))²+--+-- 本関数は与えられた特徴行列 @feats@ (= 既に ω/b/σ_f が決まったもの) と+-- Ridge λ に対して LOOCV を返す。 グリッドサーチ側ではこれを多数の λ で+-- 呼び出すが、 Φ は 1 度だけ計算すれば良いので外側でキャッシュする。+-- [English]: Closed-form LOOCV for RFF ridge regression using a Cholesky+-- factorization plus the hat-matrix diagonal.+--+-- H = Φ (ΦᵀΦ + λI)⁻¹ Φᵀ+-- ŷ = H y+-- LOOCV(λ) = (1/n) Σᵢ ((y_i - ŷ_i) / (1 - H_ii))²+--+-- This function returns the LOOCV for a given feature matrix @feats@+-- (with ω\/b\/σ_f already fixed) and Ridge λ. The grid-search side calls+-- this repeatedly for many λ values, so Φ only needs to be computed once+-- and is cached by the caller.+loocvRFFRidgeMV+ :: RFFFeaturesMV+ -> LA.Matrix Double -- ^ X (n × p)+ -> LA.Vector Double -- ^ y (n)+ -> Double -- ^ λ+ -> Double+loocvRFFRidgeMV feats x y lam =+ let phi = rffFeaturesMV feats x -- n × D+ in loocvFromPhi phi y lam++-- | [日本語]: Φ から LOOCV を計算する内部実装 (グリッドサーチでキャッシュ用)。+-- Cholesky ベース (Φ_ridge = Φᵀ Φ + λI、 A = chol(Φ_ridge))。+-- H = Φ Φ_ridge⁻¹ Φᵀ+-- T = Φ Φ_ridge⁻¹ → diag(H) = row-sum(T ⊙ Φ)+-- [English]: Internal implementation that computes the LOOCV from Φ+-- (for caching on the grid-search side). Cholesky-based+-- (Φ_ridge = Φᵀ Φ + λI, A = chol(Φ_ridge)).+-- H = Φ Φ_ridge⁻¹ Φᵀ+-- T = Φ Φ_ridge⁻¹ → diag(H) = row-sum(T ⊙ Φ)+loocvFromPhi :: LA.Matrix Double -> LA.Vector Double -> Double -> Double+loocvFromPhi phi y lam =+ let n = LA.rows phi+ d = LA.cols phi+ gram = LA.tr phi LA.<> phi -- D × D+ regK = gram + LA.scale lam (LA.ident d)+ -- 解析解: w = regK⁻¹ Φᵀ y+ w = regK LA.<\> (LA.tr phi LA.#> y)+ yhat = phi LA.#> w+ -- diag(H) = diag(Φ M Φᵀ) where M = regK⁻¹+ -- T = Φ M (n × D)。Φ M Φᵀ の対角 = row(T) · row(Φ)+ tMat = LA.tr (regK LA.<\> LA.tr phi) -- T = Φ M、n × D+ hDiag = LA.fromList+ [ LA.dot (LA.flatten (tMat LA.? [i]))+ (LA.flatten (phi LA.? [i]))+ | i <- [0 .. n - 1] ]+ -- 1 - H_ii の極小ガード+ oneMinusH = LA.cmap (\h -> max 1e-12 (1 - h)) hDiag+ resid = y - yhat+ ratios = LA.toList resid `divList` LA.toList oneMinusH+ sse = sum [ r * r | r <- ratios ]+ in sse / fromIntegral (max 1 n)+ where+ divList xs ys = zipWith (/) xs ys++-- | [日本語]: log 等間隔の @(ℓ, λ)@ グリッドを探索し、 最小の LOOCV を求める。+--+-- ℓ ごとに ω を新規サンプリングするため IO。 グリッドサイズ default (8, 20):+-- ℓ 8 点 × λ 20 点 = 160 fit。 各 fit O(n D + D³) で n=545, D=200 程度なら+-- 全体で数秒程度。+--+-- σ_f は std(y) 固定 (Ridge ↔ GP 等価では σ_f は ω 分散と一緒に動くべきだが、+-- λ で吸収できるので簡易化)。+-- [English]: Search a log-spaced @(ℓ, λ)@ grid for the smallest LOOCV.+--+-- IO because @ω@ is resampled for each @ℓ@. Default grid size (8, 20):+-- 8 points for @ℓ@ × 20 points for @λ@ = 160 fits. With each fit costing+-- O(n D + D³), the whole search takes a few seconds for n=545, D=200 or+-- so.+--+-- @σ_f@ is fixed to std(y) (under the Ridge ↔ GP equivalence, @σ_f@+-- should ideally move together with the @ω@ variance, but it can be+-- absorbed by @λ@, so this is simplified).+gridSearchLOOCVRBFMV+ :: Int -- ^ [日本語]: p (入力次元)。 [English]: p (input dimension).+ -> Int -- ^ [日本語]: D (特徴次元)。 [English]: D (feature dimension).+ -> LA.Matrix Double -- ^ X+ -> LA.Vector Double -- ^ y+ -> Maybe (Int, Int) -- ^ (n_ℓ, n_λ) default (8, 20)+ -> GenIO+ -> IO LOOCVResult+gridSearchLOOCVRBFMV p d x y mGrid gen = do+ let (nL, nLam) = case mGrid of { Just g -> g; Nothing -> (8, 20) }+ yStd = sampleStd (LA.toList y)+ sf = max 1e-9 yStd+ ellM = max 1e-3 (medianPairwiseDist x)+ ellGrid = logSpace (ellM * 0.05) (ellM * 20) nL+ lamGrid = logSpace (yStd * 1e-6) (yStd * 10) nLam+ -- 各 ℓ について 1 度サンプリングしてから λ ループ+ evals <- mapM (\ell -> do+ feats <- sampleRFFRBFMV p d ell sf gen+ let phi = rffFeaturesMV feats x+ let scoresAtLam = [ (ell, sf, lam, loocvFromPhi phi y lam)+ | lam <- lamGrid ]+ return scoresAtLam)+ ellGrid+ let evaluations = concat evals+ best = foldr1 (\a@(_,_,_,la) b@(_,_,_,lb) ->+ if la <= lb then a else b) evaluations+ (bEll, bSf, bLam, bL) = best+ return LOOCVResult+ { lcEll = bEll+ , lcSigmaF = bSf+ , lcLambda = bLam+ , lcLOOCV = bL+ , lcGridPts = nL * nLam+ }++-- | [日本語]: 'gridSearchLOOCVRBFMV' の Differential-Evolution 版。+--+-- (log_ℓ, log_λ) の 2 次元空間を Differential Evolution で探索。+-- ω は ℓ ごとに新規サンプリング (RFF の特性上避けられない) のでコストは+-- グリッド版と同程度。 グリッドの離散性が問題になる場合に有効。+-- [English]: Differential-Evolution variant of 'gridSearchLOOCVRBFMV'.+--+-- Searches the 2-dimensional space (log_ℓ, log_λ) with Differential+-- Evolution. @ω@ is resampled for each @ℓ@ (unavoidable given RFF's+-- nature), so the cost is comparable to the grid version. Useful when+-- the discreteness of the grid is a problem.+gridSearchLOOCVRBFMV_DE+ :: Int -- ^ [日本語]: p (入力次元)。 [English]: p (input dimension).+ -> Int -- ^ [日本語]: D (特徴次元)。 [English]: D (feature dimension).+ -> LA.Matrix Double -- ^ X+ -> LA.Vector Double -- ^ y+ -> Int -- ^ DE generations+ -> System.Random.MWC.GenIO+ -> IO LOOCVResult+gridSearchLOOCVRBFMV_DE p d x y nGen gen = do+ let yStd = sampleStd (LA.toList y)+ sf = max 1e-9 yStd+ ellM = max 1e-3 (medianPairwiseDist x)+ bounds =+ [ (log (ellM * 0.05), log (ellM * 20)) -- log ℓ+ , (log (yStd * 1e-6), log (yStd * 10)) -- log λ+ ]+ -- 目的関数: log-space で受けた (log_ell, log_lam) で LOOCV を返す。+ -- ω サンプリングは IO を含むため `unsafePerformIO` を使うが、決定的シードを+ -- 内部で固定しないと毎回違う値が出る。簡略化のため: ℓ ごとに 1 度だけ+ -- サンプリングしたかったが、純粋関数化のため IO Ref キャッシュは省略。+ -- 各 DE 評価で feats を再サンプル (ノイズが入るが、実用上は最終 best 周辺で+ -- 十分平均化される)。+ --+ -- 評価をプリ計算: 候補集団のサイズ × generations 回 fresh sample。+ let cfg = (DEM.defaultDEConfig bounds)+ { DEM.deStop = OCM.defaultStopCriteria { OCM.stMaxIter = nGen } }+ -- ω サンプリング用の固定シード生成器を別途準備+ -- (DE 内のランダムは gen を共有、評価用の ω は新たに引く)+ obj <- pure $ \[le, llam] ->+ System.IO.Unsafe.unsafePerformIO $ do+ let ell = exp le+ lam = exp llam+ feats <- sampleRFFRBFMV p d ell sf gen+ let phi = rffFeaturesMV feats x+ pure (loocvFromPhi phi y lam)+ r <- DEM.runDEWith cfg obj gen+ let [le, llam] = OCM.orBest r+ bestEll = exp le+ bestLam = exp llam+ bestL = OCM.orValue r+ return LOOCVResult+ { lcEll = bestEll+ , lcSigmaF = sf+ , lcLambda = bestLam+ , lcLOOCV = bestL+ , lcGridPts = OCM.orIters r * DEM.dePopSize cfg+ }++-- | [日本語]: 'gridSearchLOOCVRBFMV' のベイズ最適化版+-- (金子流: 初期点 + GP 代理モデル + 獲得関数で評価回数を削減)。+--+-- グリッドの 160 点 (8 ℓ × 20 λ) に対し、 既定 30 評価 (init 8 + iter 22) で+-- 同等の @(ℓ, λ)@ を (log ℓ, log λ) の 2 次元 BO ('BO.bayesOptND') で求める。+--+-- __RFF + BO の肝__: RFF の周波数 ω~N(0, 1/ℓ) はランダムなので、 同じ @(ℓ,λ)@+-- でも引き直すと LOOCV が変わる (stochastic)。 BO は決定的目的関数を仮定する+-- ため、 ここでは __基底 ω₀~N(0,1) と bias b を 1 度だけ引いて固定__し、 ℓ+-- ごとに @ω = ω₀ / ℓ@ とスケールする。 これで LOOCV(ℓ,λ) は ℓ の決定的関数+-- になり、 GP 代理が綺麗に乗る。 ℓ ごとに ω を引き直す grid \/ DE 版 (上記) より+-- MC ノイズが小さく __むしろ安定__。 D を上げるほど RFF の分散は減る。+-- [English]: Bayesian-optimization variant of 'gridSearchLOOCVRBFMV'+-- (Kaneko-style: reduces the number of evaluations with initial points ++-- a GP surrogate model + an acquisition function).+--+-- For the 160-point grid (8 @ℓ@ × 20 @λ@), the equivalent @(ℓ, λ)@ is+-- found with a default of 30 evaluations (init 8 + iter 22) via a+-- 2-dimensional BO over (log ℓ, log λ) ('BO.bayesOptND').+--+-- __The key to RFF + BO__: because the RFF frequencies ω~N(0, 1/ℓ) are+-- random, resampling changes the LOOCV even for the same @(ℓ,λ)@+-- (stochastic). Since BO assumes a deterministic objective function,+-- here the __base ω₀~N(0,1) and bias b are drawn once and fixed__, and+-- scaled per @ℓ@ as @ω = ω₀ / ℓ@. This makes LOOCV(ℓ,λ) a deterministic+-- function of @ℓ@, letting the GP surrogate fit cleanly. This has less+-- MC noise — __in fact it is more stable__ — than the grid \/ DE+-- versions above that resample @ω@ per @ℓ@. The larger @D@ is, the+-- smaller the RFF variance becomes.+bayesOptLOOCVRBFMV+ :: Int -- ^ [日本語]: p (入力次元)。 [English]: p (input dimension).+ -> Int -- ^ [日本語]: D (特徴次元)。 [English]: D (feature dimension).+ -> LA.Matrix Double -- ^ X+ -> LA.Vector Double -- ^ y+ -> Maybe (Int, Int) -- ^ [日本語]: (initPoints, iterations) default (8, 22) = 30 評価。 [English]: (initPoints, iterations), default (8, 22) = 30 evaluations.+ -> System.Random.MWC.GenIO+ -> IO LOOCVResult+bayesOptLOOCVRBFMV p d x y mBudget gen = do+ let (nInit, nIter) = case mBudget of { Just b -> b; Nothing -> (8, 22) }+ yStd = sampleStd (LA.toList y)+ sf = max 1e-9 yStd+ ellM = max 1e-3 (medianPairwiseDist x)+ bounds =+ [ (log (ellM * 0.05), log (ellM * 20)) -- log ℓ+ , (log (yStd * 1e-6), log (yStd * 10)) -- log λ+ ]+ -- 基底周波数 ω₀~N(0,1) + bias b を 1 度だけ引いて固定 (= 決定的目的関数化)。+ ws0 <- V.replicateM (p * d) (MWCD.normal 0 1 gen)+ bs <- V.replicateM d (uniformR (0, 2 * pi) gen)+ let omega0 = LA.reshape d (LA.fromList (V.toList ws0)) -- p × d (ℓ=1 相当)+ featsAt ell =+ RFFFeaturesMV+ { rffmvKernel = RFFRBF+ , rffmvDim = p+ , rffmvOmegas = LA.scale (1 / ell) omega0 -- ω = ω₀ / ℓ+ , rffmvBs = bs+ , rffmvSigmaF = sf+ , rffmvLengthScale = ell+ }+ objective [le, llam] =+ let ell = exp le+ lam = exp llam+ phi = rffFeaturesMV (featsAt ell) x+ in pure (loocvFromPhi phi y lam)+ objective _ = pure (1 / 0) -- 次元不一致は +∞ (起き得ないが total に)+ cfg = BO.defaultBayesOptConfig+ { BO.boInitPoints = nInit, BO.boIterations = nIter }+ (_history, (bestXs, bestL)) <- BO.bayesOptND cfg 8 objective bounds gen+ let (bestEll, bestLam) = case bestXs of+ (le : llam : _) -> (exp le, exp llam)+ _ -> (ellM, yStd * 1e-3)+ return LOOCVResult+ { lcEll = max 1e-6 bestEll+ , lcSigmaF = sf+ , lcLambda = max 1e-8 bestLam+ , lcLOOCV = bestL+ , lcGridPts = nInit + nIter+ }++-- | [日本語]: 'gridSearchLOOCVRBFMV' の L-BFGS 版 (固定基底 + 数値勾配 L-BFGS の+-- 多始点)。+--+-- 'bayesOptLOOCVRBFMV' と同じく __基底 ω₀~N(0,1) を 1 度引いて固定__し ℓ で+-- スケールすることで LOOCV(log ℓ, log λ) を決定的・微分可能化し、 数値勾配+-- L-BFGS ('LBFGS.runLBFGSNumeric'、 GP の @optimizeGP@ と同じ engine) を複数+-- 始点から回して LOOCV 最小を採る。 GP の多始点 L-BFGS の RFF 版で、 評価は+-- O(D³) なので大 n でもスケーラブル (厳密 GP marginal likelihood の O(n³) を+-- 回避)。 grid の離散性も BO の粗いサロゲートも避け、 連続最適化で (ℓ,λ) を+-- 精密に当てる。+-- [English]: L-BFGS variant of 'gridSearchLOOCVRBFMV' (fixed basis ++-- multi-start numerical-gradient L-BFGS).+--+-- As with 'bayesOptLOOCVRBFMV', __the base ω₀~N(0,1) is drawn once and fixed__,+-- then scaled by ℓ, making LOOCV(log ℓ, log λ) deterministic and+-- differentiable. Numerical-gradient L-BFGS+-- ('LBFGS.runLBFGSNumeric', the same engine as GP's @optimizeGP@) is run+-- from multiple starting points to find the LOOCV minimum. This is the+-- RFF analogue of GP's multi-start L-BFGS; evaluation is O(D³), so it+-- scales even for large n (avoiding the O(n³) of the exact GP marginal+-- likelihood). It avoids both grid discreteness and BO's coarse+-- surrogate, pinpointing @(ℓ,λ)@ via continuous optimization.+lbfgsLOOCVRBFMV+ :: Int -- ^ [日本語]: p (入力次元)。 [English]: p (input dimension).+ -> Int -- ^ [日本語]: D (特徴次元)。 [English]: D (feature dimension).+ -> LA.Matrix Double -- ^ X+ -> LA.Vector Double -- ^ y+ -> Maybe Int -- ^ [日本語]: multi-start 数 (default 4)。 [English]: Number of multi-starts (default 4).+ -> System.Random.MWC.GenIO+ -> IO LOOCVResult+lbfgsLOOCVRBFMV p d x y mStarts gen = do+ let nStarts = max 1 (case mStarts of { Just n -> n; Nothing -> 3 })+ yStd = sampleStd (LA.toList y)+ sf = max 1e-9 yStd+ ellM = max 1e-3 (medianPairwiseDist x)+ logEll0 = log ellM+ logLam0 = log (max 1e-12 (yStd * 1e-3))+ -- 基底 ω₀ + bias を 1 度だけ引いて固定 (= 決定的・微分可能化)。+ ws0 <- V.replicateM (p * d) (MWCD.normal 0 1 gen)+ bs <- V.replicateM d (uniformR (0, 2 * pi) gen)+ let omega0 = LA.reshape d (LA.fromList (V.toList ws0))+ featsAt ell =+ RFFFeaturesMV+ { rffmvKernel = RFFRBF+ , rffmvDim = p+ , rffmvOmegas = LA.scale (1 / ell) omega0+ , rffmvBs = bs+ , rffmvSigmaF = sf+ , rffmvLengthScale = ell+ }+ -- LOOCV (最小化対象、 lbDir 既定 = Minimize)。+ obj [le, llam] = loocvFromPhi (rffFeaturesMV (featsAt (exp le)) x) y (exp llam)+ obj _ = 1 / 0+ -- 2D 目的なので maxIter は控えめで十分収束 (数値勾配が高 D で高コストなため+ -- 評価数を抑える)。 multi-start で局所性をカバー。+ cfg = LBFGS.defaultLBFGSConfig+ { LBFGS.lbStop = OCM.defaultStopCriteria+ { OCM.stMaxIter = 40, OCM.stTolFun = 1e-8 } }+ -- 多始点: base + (nStarts-1) ランダム摂動 (log 空間 正規)。+ perturbs <- mapM (\_ -> do+ ze <- MWCD.normal 0 1.5 gen+ zl <- MWCD.normal 0 2.0 gen+ pure [logEll0 + ze, logLam0 + zl])+ [1 .. nStarts - 1]+ results <- mapM (LBFGS.runLBFGSNumeric cfg obj) ([logEll0, logLam0] : perturbs)+ let isFin v = not (isNaN v || isInfinite v)+ scored = [ (OCM.orBest r, OCM.orValue r) | r <- results, isFin (OCM.orValue r) ]+ (bestX, bestVal) = case scored of+ [] -> ([logEll0, logLam0], obj [logEll0, logLam0])+ _ -> foldr1 (\a b -> if snd a <= snd b then a else b) scored+ (bLe, bLlam) = case bestX of+ (a : b : _) -> (a, b)+ _ -> (logEll0, logLam0)+ return LOOCVResult+ { lcEll = max 1e-6 (exp bLe)+ , lcSigmaF = sf+ , lcLambda = max 1e-8 (exp bLlam)+ , lcLOOCV = bestVal+ , lcGridPts = nStarts+ }
+ src/Hanalyze/Model/RandomForest.hs view
@@ -0,0 +1,471 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE BangPatterns #-}+-- |+-- Module : Hanalyze.Model.RandomForest+-- Description : 回帰用 Random Forest (CART + bagging + random feature subset、行インデックス置換方式)+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- Random forest for regression (CART + bagging + random feature subset).+--+-- /Performance/: this module was ported in B9b from a list-based+-- implementation to a row-index permutation scheme, mirroring the+-- 'Hanalyze.Model.DecisionTree' refactor:+--+-- * Single shared @LA.Matrix Double@ feature matrix.+-- * @VU.Vector Int@ row indices recurse through subtrees.+-- * Per-feature best split via 'Data.Vector.Algorithms.Intro' sort+-- and incremental sum / sum-of-squares sweep.+-- * Bootstrap = random index Vector (no row data copied).+--+-- The classic 'fitRF' over @[[Double]] / [Double]@ is preserved as a+-- backwards-compatibility wrapper that calls 'fitRFV'.+module Hanalyze.Model.RandomForest+ ( -- * Single regression tree+ Tree (..)+ , RFConfig (..)+ , defaultRandomForest+ , buildTree+ , buildTreeV+ , predictTree+ -- * Forest+ , RandomForest (..)+ , fitRF+ , fitRFV+ , fitRFPure+ , fitRFVPure+ , predictRF+ , featureImportance+ , rfPermutationImportance+ , defaultFeatureNames+ ) where++import qualified Data.Vector as V+import qualified Data.Vector.Mutable as VM+import qualified Data.Vector.Unboxed as VU+import qualified Data.Vector.Unboxed.Mutable as VUM+import qualified Data.Vector.Algorithms.Intro as Intro+import qualified Numeric.LinearAlgebra as LA+import qualified System.Random.MWC as MWC+import Control.Monad (replicateM)+import Control.Monad.Primitive (PrimMonad, PrimState)+import Control.Monad.ST (runST)+import Data.Word (Word32)+import Data.Text (Text)+import qualified Data.Text as T++-- ---------------------------------------------------------------------------+-- Types+-- ---------------------------------------------------------------------------++-- | A regression tree node.+data Tree+ = Leaf !Double+ | Node !Int !Double !Tree !Tree+ deriving (Show)++-- | Random-forest configuration.+data RFConfig = RFConfig+ { rfTrees :: !Int+ , rfMaxDepth :: !Int+ , rfMinSamples :: !Int+ , rfMtry :: !(Maybe Int)+ , rfBootstrap :: !Bool+ } deriving (Show)++defaultRandomForest :: RFConfig+defaultRandomForest = RFConfig+ { rfTrees = 100+ , rfMaxDepth = 12+ , rfMinSamples = 3+ , rfMtry = Nothing+ , rfBootstrap = True+ }++data RandomForest = RandomForest+ { rfTreesV :: ![Tree]+ , rfNFeatures :: !Int+ , rfImportance :: !(V.Vector Double) -- ^ [日本語]: impurity/split ベース (MDI 相当・R IncNodePurity)。 [English]: Impurity\/split-based importance (equivalent to MDI; R's IncNodePurity).+ , rfPermImportance :: !(V.Vector Double) -- ^ [日本語]: permutation ベース (MSE 増加・R %IncMSE・sklearn permutation_importance)。 [English]: Permutation-based importance (MSE increase; R's %IncMSE, sklearn's permutation_importance).+ , rfFeatureNames :: ![Text] -- ^ [日本語]: 特徴列名。 df|-> 経路が実列名を設定、 低レベル行列 fit は 'defaultFeatureNames' ("f1"..)。 [English]: Feature column names. The df|-> path sets the real column names; low-level matrix fits use 'defaultFeatureNames' ("f1"..).+ } deriving (Show)++-- | [日本語]: 名前を持たない行列入力の既定特徴名 ("f1", "f2", …・1 始まり = R/sklearn 慣例)。+-- [English]: Default feature names for unnamed matrix input ("f1", "f2",+-- …; 1-indexed, following R\/sklearn convention).+defaultFeatureNames :: Int -> [Text]+defaultFeatureNames d = [ "f" <> T.pack (show k) | k <- [1 .. d] ]++-- ---------------------------------------------------------------------------+-- Vector-based fit (primary)+-- ---------------------------------------------------------------------------++-- | [日本語]: IO ラッパ。 ロジックは 'PrimMonad' 汎用の 'fitRFVM' を共有+-- (mwc は 'PrimMonad' 汎用ゆえ ST/IO 両経路で同コード)。+-- [English]: An IO wrapper. The logic is shared with the 'PrimMonad'-generic+-- 'fitRFVM' (since mwc is 'PrimMonad'-generic, both the ST and IO paths+-- share the same code).+fitRFV :: RFConfig+ -> LA.Matrix Double+ -> VU.Vector Double+ -> MWC.GenIO+ -> IO RandomForest+fitRFV = fitRFVM++-- | [日本語]: 'PrimMonad' 汎用の forest 本体。 'fitRFV' (IO) / @fitRFVPure@ (ST) が共有。+-- 乱数 (gen) は bootstrap index のみで使う。 木構築 'buildTreeV' と feature+-- importance は純粋ゆえ ST/IO でビット同一。+-- [English]: The 'PrimMonad'-generic forest core, shared by 'fitRFV' (IO)+-- and @fitRFVPure@ (ST). The random generator (gen) is used only for the+-- bootstrap index. Tree building ('buildTreeV') and feature importance are+-- pure, so ST and IO give bit-identical results.+fitRFVM :: PrimMonad m+ => RFConfig+ -> LA.Matrix Double+ -> VU.Vector Double+ -> MWC.Gen (PrimState m)+ -> m RandomForest+fitRFVM cfg x y gen = do+ let !n = VU.length y+ !d = LA.cols x+ trees <- replicateM (rfTrees cfg) $ do+ !idx <- if rfBootstrap cfg+ then bootstrapIdxM n gen+ else pure (VU.enumFromN 0 n)+ pure $! buildTreeV cfg x y idx 0+ -- permutation importance は列シャッフルに gen を使う (bootstrap の後・seed 決定的)。+ !perm <- permImportanceRegM x y trees gen+ pure RandomForest+ { rfTreesV = trees+ , rfNFeatures = d+ , rfImportance = importanceOf d trees+ , rfPermImportance = perm+ , rfFeatureNames = defaultFeatureNames d+ }++-- | Backwards-compatible list-based fit.+fitRF :: RFConfig -> [[Double]] -> [Double] -> MWC.GenIO -> IO RandomForest+fitRF cfg xs ys gen+ | null xs = pure emptyForest+ | otherwise = fitRFV cfg (LA.fromLists xs) (VU.fromList ys) gen++-- | [日本語]: 純粋・決定的な行列入力 forest。 同じ @seed@ なら必ず同じ 'RandomForest'。+-- 'fitRFVM' を @ST@ で走らせ 'runST' で閉じる+-- ([[phase-50-mcmc-purification-status]] の @nutsPure@ と同方針)。+-- [English]: A pure, deterministic matrix-input forest. The same @seed@+-- always yields the same 'RandomForest'. Runs 'fitRFVM' in @ST@ and closes+-- it with 'runST' (the same approach as @nutsPure@ in+-- [[phase-50-mcmc-purification-status]]).+fitRFVPure :: RFConfig+ -> LA.Matrix Double+ -> VU.Vector Double+ -> Word32+ -> RandomForest+fitRFVPure cfg x y seed =+ runST (MWC.initialize (V.singleton seed) >>= fitRFVM cfg x y)++-- | [日本語]: 純粋・決定的な list 入力 forest (list 版 'fitRF' の seed 純粋版)。+-- [English]: A pure, deterministic list-input forest (the seed-pure+-- version of the list-based 'fitRF').+fitRFPure :: RFConfig -> [[Double]] -> [Double] -> Word32 -> RandomForest+fitRFPure cfg xs ys seed+ | null xs = emptyForest+ | otherwise = fitRFVPure cfg (LA.fromLists xs) (VU.fromList ys) seed++-- | [日本語]: 空データ時の forest (全フィールド空)。+-- [English]: The forest for empty data (all fields empty).+emptyForest :: RandomForest+emptyForest = RandomForest [] 0 V.empty V.empty []++-- | Single-tree builder kept for the symmetry of the old API. Most+-- callers should use 'fitRFV'.+buildTree :: RFConfig -> [[Double]] -> [Double] -> MWC.GenIO -> IO Tree+buildTree cfg rows ys gen+ | null rows = pure (Leaf 0)+ | otherwise = do+ let !x = LA.fromLists rows+ !y = VU.fromList ys+ !n = VU.length y+ idx <- if rfBootstrap cfg+ then bootstrapIdxM n gen+ else pure (VU.enumFromN 0 n)+ pure (buildTreeV cfg x y idx 0)++bootstrapIdxM :: PrimMonad m => Int -> MWC.Gen (PrimState m) -> m (VU.Vector Int)+bootstrapIdxM n gen =+ VU.replicateM n (MWC.uniformR (0, n - 1) gen)++-- ---------------------------------------------------------------------------+-- Recursive build+-- ---------------------------------------------------------------------------++buildTreeV :: RFConfig+ -> LA.Matrix Double+ -> VU.Vector Double+ -> VU.Vector Int+ -> Int+ -> Tree+buildTreeV cfg x y idx depth =+ let !n = VU.length idx+ !subY = VU.map (y VU.!) idx+ !meanY = if n == 0 then 0+ else VU.sum subY / fromIntegral n+ !varY = varianceUS subY+ in if n <= rfMinSamples cfg+ || depth >= rfMaxDepth cfg+ || varY < 1e-12+ then Leaf meanY+ else+ let !d = LA.cols x+ !mtry = case rfMtry cfg of+ Just m -> max 1 (min d m)+ Nothing -> max 1 (d `div` 3)+ !featIxs = pickFeats d mtry depth n+ !mBest = bestSplitVRF featIxs x y idx+ in case mBest of+ Nothing -> Leaf meanY+ Just (j, thr, _) ->+ let (lIdx, rIdx) = partitionByFeat x idx j thr+ in if VU.null lIdx || VU.null rIdx+ then Leaf meanY+ else Node j thr+ (buildTreeV cfg x y lIdx (depth + 1))+ (buildTreeV cfg x y rIdx (depth + 1))++-- | Deterministic pseudo-random feature subset using an LCG seeded by+-- @(depth, n)@. Different nodes typically see different subsets,+-- which is the decorrelation that random forests need at split time.+-- Tree-level randomness comes from @bootstrapIdx@, which threads+-- through 'MWC.GenIO'.+pickFeats :: Int -> Int -> Int -> Int -> VU.Vector Int+pickFeats d mtry depth n+ | mtry >= d = VU.enumFromN 0 d+ | otherwise =+ let seed0 = depth * 1009 + n * 31 + 1+ step !s = (s * 1103515245 + 12345) `mod` (2 ^ (31 :: Int))+ go !s !chosen !left+ | left == 0 = chosen+ | otherwise =+ let !s' = step s+ !i = s' `mod` d+ in if i `VU.elem` chosen+ then go s' chosen left+ else go s' (chosen `VU.snoc` i) (left - 1)+ in go seed0 VU.empty mtry++partitionByFeat :: LA.Matrix Double+ -> VU.Vector Int+ -> Int+ -> Double+ -> (VU.Vector Int, VU.Vector Int)+partitionByFeat x idx feat thr =+ let pred_ i = LA.atIndex x (i, feat) <= thr+ in VU.partition pred_ idx++-- ---------------------------------------------------------------------------+-- Best split+-- ---------------------------------------------------------------------------++bestSplitVRF :: VU.Vector Int+ -> LA.Matrix Double+ -> VU.Vector Double+ -> VU.Vector Int+ -> Maybe (Int, Double, Double)+bestSplitVRF featIxs x y idx+ | VU.length idx < 2 = Nothing+ | otherwise =+ let go best j =+ case bestSplitFeatureRF x y idx j of+ Nothing -> best+ Just (thr, g) ->+ case best of+ Nothing -> Just (j, thr, g)+ Just (_, _, gPrev) | g > gPrev -> Just (j, thr, g)+ | otherwise -> best+ in VU.foldl' go Nothing featIxs++-- | Per-feature best split for regression: maximise variance reduction+-- via single sort + linear sweep with running sum / sum-of-squares.+bestSplitFeatureRF :: LA.Matrix Double+ -> VU.Vector Double+ -> VU.Vector Int+ -> Int+ -> Maybe (Double, Double)+bestSplitFeatureRF x y idx feat = runST $ do+ let !n = VU.length idx+ pairs <- VUM.new n+ let valOf i = LA.atIndex x (i, feat)+ yOf i = y VU.! i+ fill !k+ | k == n = pure ()+ | otherwise = do+ let !i = VU.unsafeIndex idx k+ VUM.unsafeWrite pairs k (valOf i, yOf i)+ fill (k + 1)+ fill 0+ Intro.sortBy (\a b -> compare (fst a) (fst b)) pairs+ pairsF <- VU.unsafeFreeze pairs++ let !sumY = VU.sum (VU.map snd pairsF)+ !sumY2 = VU.sum (VU.map (\(_, v) -> v * v) pairsF)+ !nD = fromIntegral n :: Double+ !parentSS = sumY2 - sumY * sumY / nD++ let sweep !k !sumYL !sumY2L !bestThr !bestGain+ | k >= n - 1 = pure (bestThr, bestGain)+ | otherwise = do+ let (v_k, yk) = VU.unsafeIndex pairsF k+ (v_k1, _) = VU.unsafeIndex pairsF (k + 1)+ !sumYL' = sumYL + yk+ !sumY2L' = sumY2L + yk * yk+ if v_k == v_k1+ then sweep (k + 1) sumYL' sumY2L' bestThr bestGain+ else do+ let !nL = fromIntegral (k + 1) :: Double+ !nR = nD - nL+ !sumYR = sumY - sumYL'+ !sumY2R = sumY2 - sumY2L'+ !ssL = sumY2L' - sumYL' * sumYL' / nL+ !ssR = sumY2R - sumYR * sumYR / nR+ !gain = parentSS - ssL - ssR+ !thr = (v_k + v_k1) / 2+ if gain > bestGain+ then sweep (k + 1) sumYL' sumY2L' thr gain+ else sweep (k + 1) sumYL' sumY2L' bestThr bestGain+ (thr, gain) <- sweep 0 0 0 0 (negate (1.0 / 0.0))+ pure $ if gain == negate (1.0 / 0.0)+ then Nothing+ else Just (thr, gain)++-- ---------------------------------------------------------------------------+-- Variance helper+-- ---------------------------------------------------------------------------++varianceUS :: VU.Vector Double -> Double+varianceUS v+ | VU.length v <= 1 = 0+ | otherwise =+ let !n = fromIntegral (VU.length v) :: Double+ !mu = VU.sum v / n+ in VU.foldl' (\acc x -> acc + (x - mu) ^ (2 :: Int)) 0 v / n++-- ---------------------------------------------------------------------------+-- Predict+-- ---------------------------------------------------------------------------++predictTree :: Tree -> [Double] -> Double+predictTree (Leaf v) _ = v+predictTree (Node j thr l r) xs =+ if (xs !! j) <= thr then predictTree l xs else predictTree r xs++predictRF :: RandomForest -> [Double] -> Double+predictRF rf xs =+ let preds = map (`predictTree` xs) (rfTreesV rf)+ n = length preds+ in if n == 0 then 0 else sum preds / fromIntegral n++featureImportance :: RandomForest -> V.Vector Double+featureImportance rf =+ let raw = rfImportance rf+ tot = V.sum raw+ in if tot <= 0 then raw else V.map (/ tot) raw++-- | [日本語]: Permutation importance (= 列を無作為置換したときの MSE 増加) を正の総和で+-- 正規化して返す。 全て非正なら raw のまま (負 = その特徴が予測に無寄与)。+-- R @randomForest %IncMSE@ / sklearn @permutation_importance@ 同方式。+-- [English]: Returns permutation importance (= the MSE increase when a+-- column is randomly permuted), normalized by the sum of the positive+-- values. If all values are non-positive, returns the raw values unchanged+-- (negative = that feature contributes nothing to prediction). The same+-- method as R's @randomForest %IncMSE@ \/ sklearn's+-- @permutation_importance@.+rfPermutationImportance :: RandomForest -> V.Vector Double+rfPermutationImportance rf =+ let raw = rfPermImportance rf+ tot = V.sum (V.filter (> 0) raw)+ in if tot <= 0 then raw else V.map (/ tot) raw++-- ---------------------------------------------------------------------------+-- Permutation importance (MSE 増加ベース)+-- ---------------------------------------------------------------------------++-- | [日本語]: 各特徴列を無作為置換し、 forest の MSE 増加量を測る (純粋・'PrimMonad')。+-- gen は列シャッフルにのみ使う。 同 seed → ビット同一。+-- [English]: Randomly permutes each feature column and measures the+-- forest's MSE increase (pure, 'PrimMonad'-generic). gen is used only for+-- column shuffling; the same seed gives bit-identical results.+permImportanceRegM :: PrimMonad m+ => LA.Matrix Double -> VU.Vector Double -> [Tree]+ -> MWC.Gen (PrimState m) -> m (V.Vector Double)+permImportanceRegM x y trees gen+ | LA.rows x == 0 || null trees = pure (V.replicate (LA.cols x) 0)+ | otherwise = do+ let !base = forestMSE x y trees+ scores <- mapM (\j -> do+ xp <- permuteColM j x gen+ pure $! forestMSE xp y trees - base)+ [0 .. LA.cols x - 1]+ pure (V.fromList scores)++-- | [日本語]: forest の平均二乗誤差 (行毎に木予測を平均)。+-- [English]: The forest's mean squared error (averages the per-tree+-- predictions per row).+forestMSE :: LA.Matrix Double -> VU.Vector Double -> [Tree] -> Double+forestMSE x y trees =+ let !n = LA.rows x+ !k = length trees+ rowPred i =+ let row = LA.toList (LA.flatten (x LA.? [i]))+ preds = map (`predictTree` row) trees+ in if k == 0 then 0 else sum preds / fromIntegral k+ sse = sum [ (rowPred i - y VU.! i) ^ (2 :: Int) | i <- [0 .. n - 1] ]+ in if n == 0 then 0 else sse / fromIntegral n++-- | [日本語]: 列 j を Fisher-Yates で置換した行列を返す (他列は不変)。+-- [English]: Returns the matrix with column j permuted via Fisher-Yates+-- (the other columns are unchanged).+permuteColM :: PrimMonad m+ => Int -> LA.Matrix Double -> MWC.Gen (PrimState m) -> m (LA.Matrix Double)+permuteColM j x gen = do+ let cols0 = LA.toColumns x+ colj = VU.fromList (LA.toList (cols0 !! j))+ shuf <- fisherYatesM gen colj+ let newCols = [ if kk == j then LA.fromList (VU.toList shuf) else cols0 !! kk+ | kk <- [0 .. length cols0 - 1] ]+ pure (LA.fromColumns newCols)++-- | [日本語]: 可変ベクトル上の Fisher-Yates シャッフル ('PrimMonad'・gen 決定的)。+-- [English]: A Fisher-Yates shuffle over a mutable vector ('PrimMonad'+-- -generic; deterministic given gen).+fisherYatesM :: PrimMonad m+ => MWC.Gen (PrimState m) -> VU.Vector Double -> m (VU.Vector Double)+fisherYatesM gen v0 = do+ mv <- VU.thaw v0+ let go i | i <= 0 = pure ()+ | otherwise = do+ j <- MWC.uniformR (0, i) gen+ VUM.swap mv i j+ go (i - 1)+ go (VUM.length mv - 1)+ VU.freeze mv++-- ---------------------------------------------------------------------------+-- Importance accumulation (per split, simple count)+-- ---------------------------------------------------------------------------++-- | [日本語]: 全木の split 特徴を 1 回の可変ベクトル走査で集計 (純粋)。 旧 @IORef@+-- 版を 'runST' + 可変ベクトルへ置換 (count の和は可換ゆえ木順不問で同値)。+-- [English]: Tallies the split features across all trees in a single+-- mutable-vector pass (pure). Replaces the old @IORef@-based version with+-- 'runST' + a mutable vector (since summing counts is commutative, this is+-- equivalent regardless of tree order).+importanceOf :: Int -> [Tree] -> V.Vector Double+importanceOf d trees = runST $ do+ v <- VM.replicate d 0.0+ let walk (Leaf _) = pure ()+ walk (Node j _ l r) = do+ VM.modify v (+ 1.0) j+ walk l+ walk r+ mapM_ walk trees+ V.freeze v
+ src/Hanalyze/Model/RandomForestClassifier.hs view
@@ -0,0 +1,259 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE BangPatterns #-}+-- |+-- Module : Hanalyze.Model.RandomForestClassifier+-- Description : Random Forest 分類版 — DecisionTree の bootstrap aggregation + OOB error + permutation importance+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- [日本語]: Random Forest __分類版__。+--+-- bootstrap aggregation of 'Hanalyze.Model.DecisionTree' (CART 分類)。+-- OOB (Out-of-Bag) error と permutation importance を併せて返す。+--+-- [English]: Random Forest, __classification version__.+--+-- Bootstrap aggregation of 'Hanalyze.Model.DecisionTree' (CART+-- classification). Also returns OOB (Out-of-Bag) error and permutation+-- importance.+module Hanalyze.Model.RandomForestClassifier+ ( RFCConfig (..)+ , defaultRFCConfig+ , RFClassifierFit (..)+ , fitRFClassifier+ , fitRFClassifierPure+ , predictRFClassifier+ ) where++import qualified Data.Vector as V+import qualified Data.Vector.Unboxed as VU+import qualified Numeric.LinearAlgebra as LA+import qualified Data.Map.Strict as Map+import Data.List (nub, sort, group, sortBy, foldl')+import Data.Ord (comparing, Down (..))+import Data.Text (Text)+import Data.Word (Word32)+import qualified System.Random.MWC as MWC+import Control.Monad (forM, replicateM)+import Control.Monad.Primitive (PrimMonad, PrimState)+import Control.Monad.ST (runST)++import qualified Hanalyze.Model.DecisionTree as DT+import Hanalyze.Model.RandomForest (defaultFeatureNames)++-- ===========================================================================+-- 型+-- ===========================================================================++data RFCConfig = RFCConfig+ { rfcNTrees :: !Int+ , rfcMaxDepth :: !(Maybe Int)+ , rfcMinSplit :: !Int+ } deriving (Show)++defaultRFCConfig :: RFCConfig+defaultRFCConfig = RFCConfig+ { rfcNTrees = 100+ , rfcMaxDepth = Just 10+ , rfcMinSplit = 2+ }++data RFClassifierFit = RFClassifierFit+ { rfcTrees :: ![DT.DTree]+ , rfcOOBSamples :: ![[Int]]+ , rfcClasses :: ![Int]+ , rfcOOBError :: !Double+ , rfcImportance :: !(LA.Vector Double) -- ^ [日本語]: permutation importance (OOB accuracy 低下)。 [English]: Permutation importance (drop in OOB accuracy).+ , rfcGiniImportance :: !(LA.Vector Double) -- ^ [日本語]: MDI (mean decrease in gini・木構造から純粋計算・sklearn feature_importances_ 同方式)。 [English]: MDI (mean decrease in Gini; computed purely from the tree structure; same approach as sklearn's feature_importances_).+ , rfcFeatureNames :: ![Text] -- ^ [日本語]: 特徴列名。 行列 fit は 'defaultFeatureNames' ("f1"..)。 実列名は df|-> 化 (後続) で。 [English]: Feature column names. Matrix fits use 'defaultFeatureNames' ("f1"..); real column names come via df|-> integration (subsequent work).+ , rfcConfig :: !RFCConfig+ } deriving (Show)++-- ===========================================================================+-- fit+-- ===========================================================================++-- | [日本語]: IO ラッパ。 ロジックは 'PrimMonad' 汎用の 'fitRFClassifierM' を共有。+-- [English]: An IO wrapper. Shares its logic with the 'PrimMonad'-generic 'fitRFClassifierM'.+fitRFClassifier+ :: RFCConfig+ -> LA.Matrix Double+ -> VU.Vector Int+ -> MWC.GenIO+ -> IO RFClassifierFit+fitRFClassifier = fitRFClassifierM++-- | [日本語]: 純粋・決定的な forest 分類器 (同 @seed@ → ビット同一)。 回帰の @fitRFVPure@ と同方針+-- ([[phase-50-mcmc-purification-status]])。 df|-> ('Fit RFCSpec') 経路が使う。+-- [English]: A pure, deterministic forest classifier (same @seed@ →+-- bit-identical). Follows the same policy as the regression+-- @fitRFVPure@ ([[phase-50-mcmc-purification-status]]). Used by the+-- df|-> ('Fit RFCSpec') path.+fitRFClassifierPure+ :: RFCConfig -> LA.Matrix Double -> VU.Vector Int -> Word32 -> RFClassifierFit+fitRFClassifierPure cfg x y seed =+ runST (MWC.initialize (V.singleton seed) >>= fitRFClassifierM cfg x y)++-- | [日本語]: 'PrimMonad' 汎用の forest 分類器本体。 gen は bootstrap index と permutation の+-- 列シャッフルにのみ使う (木構築・OOB・gini は純粋ゆえ ST/IO でビット同一)。+-- [English]: The 'PrimMonad'-generic forest classifier body. @gen@ is+-- used only for bootstrap indices and permutation column shuffling+-- (tree construction, OOB, and Gini are pure, so ST\/IO give bit-identical results).+fitRFClassifierM+ :: PrimMonad m+ => RFCConfig+ -> LA.Matrix Double+ -> VU.Vector Int+ -> MWC.Gen (PrimState m)+ -> m RFClassifierFit+fitRFClassifierM cfg x y gen = do+ let n = LA.rows x+ p = LA.cols x+ classes = sort (nub (VU.toList y))+ dtCfg = DT.defaultDecisionTree+ { DT.dtMaxDepth = rfcMaxDepth cfg+ , DT.dtMinSamplesSplit = rfcMinSplit cfg+ }+ results <- forM [1 .. rfcNTrees cfg] $ \_ -> do+ idxs <- replicateM n (MWC.uniformR (0, n - 1) gen)+ let x' = x LA.? idxs+ y' = VU.fromList [ y VU.! i | i <- idxs ]+ tree = DT.fitDTV dtCfg x' y'+ oob = filter (`notElem` idxs) [0 .. n - 1]+ pure (tree, oob)+ let trees = [ t | (t, _) <- results ]+ oobLists = [ o | (_, o) <- results ]+ oobErr = computeOOB x y trees oobLists+ -- permutation importance: fixed-seed gen for reproducibility per feature+ imp <- permImportance gen x y trees+ pure RFClassifierFit+ { rfcTrees = trees+ , rfcOOBSamples = oobLists+ , rfcClasses = classes+ , rfcOOBError = oobErr+ , rfcImportance = imp+ , rfcGiniImportance = giniImportance p trees+ , rfcFeatureNames = defaultFeatureNames p+ , rfcConfig = cfg+ }++-- | [日本語]: 各サンプルを多数決で予測。+-- [English]: Predict each sample by majority vote.+predictRFClassifier :: RFClassifierFit -> LA.Matrix Double -> V.Vector Int+predictRFClassifier fit xNew =+ V.generate (LA.rows xNew) $ \i ->+ let row = LA.toList (LA.flatten (xNew LA.? [i]))+ in majority [ DT.predictDT t row | t <- rfcTrees fit ]++-- ===========================================================================+-- 内部+-- ===========================================================================++computeOOB+ :: LA.Matrix Double -> VU.Vector Int -> [DT.DTree] -> [[Int]] -> Double+computeOOB x y trees oobLists =+ let n = LA.rows x+ voteFor s =+ let voters = [ t | (t, oob) <- zip trees oobLists, s `elem` oob ]+ in if null voters then Nothing+ else+ let row = LA.toList (LA.flatten (x LA.? [s]))+ in Just (majority [ DT.predictDT t row | t <- voters ])+ voted = [ (s, p) | s <- [0 .. n - 1]+ , Just p <- [voteFor s] ]+ nTotal = length voted+ nErr = length [ () | (s, p) <- voted, p /= (y VU.! s) ]+ in if nTotal == 0 then 0 else fromIntegral nErr / fromIntegral nTotal++majority :: [Int] -> Int+majority xs =+ let grouped = map (\g -> (head g, length g)) (group (sort xs))+ in case sortBy (comparing (Down . snd)) grouped of+ ((c, _) : _) -> c+ [] -> 0++-- | [日本語]: Mean Decrease in Impurity (gini) per feature, summed over all trees+-- (sklearn @feature_importances_@ 同方式・木構造から純粋計算)。 各内部ノードの+-- 重み付き gini 減少 @n·(imp − (nL/n)·impL − (nR/n)·impR)@ を分割特徴に加算し、+-- 全木ぶん合計 → 合計 1 に正規化。 'DT.DTree' の拡張フィールド (dnN/dnImpurity) を使う。+-- [English]: Mean Decrease in Impurity (Gini) per feature, summed over+-- all trees (the same approach as sklearn's @feature_importances_@;+-- computed purely from the tree structure). Adds the weighted Gini+-- decrease at each internal node,+-- @n·(imp − (nL/n)·impL − (nR/n)·impR)@, to its split feature, sums+-- over all trees, then normalizes to sum to 1. Uses 'DT.DTree''s+-- extension fields (dnN\/dnImpurity).+giniImportance :: Int -> [DT.DTree] -> LA.Vector Double+giniImportance p trees =+ let m0 = Map.fromList [ (j, 0 :: Double) | j <- [0 .. p - 1] ]+ go m (DT.DLeaf{}) = m+ go m (DT.DNode { DT.dnFeature = j, DT.dnLeft = l, DT.dnRight = r+ , DT.dnN = nn, DT.dnImpurity = imp }) =+ let n = fromIntegral nn :: Double+ nL = fromIntegral (nodeN l)+ nR = fromIntegral (nodeN r)+ dec = if n <= 0 then 0+ else n * (imp - (nL / n) * nodeImp l - (nR / n) * nodeImp r)+ m' = Map.insertWith (+) j dec m+ in go (go m' l) r+ accM = foldl' go m0 trees+ raw = [ Map.findWithDefault 0 j accM | j <- [0 .. p - 1] ]+ tot = sum raw+ in LA.fromList (if tot <= 0 then raw else map (/ tot) raw)++-- | [日本語]: ノードのサンプル数 / gini 不純度 (葉・内部で共通アクセス)。+-- [English]: A node's sample count \/ Gini impurity (uniform access for leaf and internal nodes).+nodeN :: DT.DTree -> Int+nodeN (DT.DLeaf{ DT.dlN = n }) = n+nodeN (DT.DNode{ DT.dnN = n }) = n++nodeImp :: DT.DTree -> Double+nodeImp (DT.DLeaf{ DT.dlImpurity = i }) = i+nodeImp (DT.DNode{ DT.dnImpurity = i }) = i++permImportance+ :: PrimMonad m+ => MWC.Gen (PrimState m) -> LA.Matrix Double -> VU.Vector Int -> [DT.DTree]+ -> m (LA.Vector Double)+permImportance gen x y trees = do+ let p = LA.cols x+ baseAcc = forestAccuracy x y trees+ scores <- forM [0 .. p - 1] $ \j -> do+ xPerm <- permuteColumn j gen x+ let acc = forestAccuracy xPerm y trees+ pure (baseAcc - acc)+ pure (LA.fromList scores)++forestAccuracy :: LA.Matrix Double -> VU.Vector Int -> [DT.DTree] -> Double+forestAccuracy x y trees =+ let n = LA.rows x+ preds =+ [ let row = LA.toList (LA.flatten (x LA.? [i]))+ in majority [ DT.predictDT t row | t <- trees ]+ | i <- [0 .. n - 1] ]+ correct = length [ () | (p_, i) <- zip preds [0 ..]+ , p_ == (y VU.! i) ]+ in fromIntegral correct / fromIntegral n++permuteColumn :: PrimMonad m+ => Int -> MWC.Gen (PrimState m) -> LA.Matrix Double -> m (LA.Matrix Double)+permuteColumn j gen x = do+ let col = LA.toList (LA.flatten (x LA.¿ [j]))+ shuf <- fisherYates gen col+ let newCol = LA.fromList shuf+ cols = [ if k == j then newCol else LA.flatten (x LA.¿ [k])+ | k <- [0 .. LA.cols x - 1] ]+ pure (LA.fromColumns cols)++fisherYates :: PrimMonad m => MWC.Gen (PrimState m) -> [a] -> m [a]+fisherYates gen xs =+ let v0 = V.fromList xs+ in go v0 (V.length v0 - 1)+ where+ go v 0 = pure (V.toList v)+ go v i = do+ j <- MWC.uniformR (0, i) gen+ let vi = v V.! i+ vj = v V.! j+ v' = v V.// [(i, vj), (j, vi)]+ go v' (i - 1)
+ src/Hanalyze/Model/Regularized.hs view
@@ -0,0 +1,764 @@+{-# LANGUAGE StrictData #-}+{-# LANGUAGE OverloadedStrings #-}+-- |+-- Module : Hanalyze.Model.Regularized+-- Description : 正則化回帰 (Ridge / Lasso / Elastic Net) を単一 API に統合したモジュール+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- [日本語]: 正則化回帰 (Ridge / Lasso / Elastic Net) を単一モジュールに統合。+--+-- ペナルティは合成型 'Penalty' として符号化され、 'fitRegularized' が+-- 以下の 4 モデルすべてを扱う:+--+-- > NoPen -- 通常の OLS+-- > L2 lambda -- Ridge 回帰+-- > L1 lambda -- Lasso 回帰+-- > ElasticNet lambda1 lambda2 -- Elastic Net (L1 + L2)+--+-- Ridge は閉形式解を持つ。 Lasso と Elastic Net は座標降下法を用いる。+--+-- 注意: Lasso / Elastic Net は X の列スケールに敏感。事前に+-- standardize (各列を平均 0、分散 1 に) しておくのが一般的。+--+-- [English]: Regularized regression (Ridge / Lasso / Elastic Net) in one+-- module.+--+-- The penalty is encoded as the sum type 'Penalty', and 'fitRegularized'+-- handles all four models:+--+-- > NoPen -- ordinary OLS+-- > L2 lambda -- Ridge regression+-- > L1 lambda -- Lasso regression+-- > ElasticNet lambda1 lambda2 -- Elastic Net (L1 + L2)+--+-- Ridge has a closed form; Lasso and Elastic Net use coordinate descent.+--+-- Note: Lasso / Elastic Net are sensitive to the column scale of X; it+-- is common practice to standardize (each column to mean 0, variance 1)+-- beforehand.+module Hanalyze.Model.Regularized+ ( Penalty (..)+ , RegFit (..)+ , fitRegularized+ , fitRidge+ , fitElasticNet+ , predictRegularized+ , standardize+ , unstandardizeBeta+ -- * Multi-output (primary API)+ , RegFitMulti (..)+ , fitRegularizedMulti+ , fitRegularizedMultiWith+ , predictRegularizedMulti+ , regFitFromMulti+ -- * Convergence-controlled API+ , fitRegularizedWith+ -- * Regularization path+ , regularizationPath++ -- * λ 自動選択 (Phase 4.4、 request/150)+ , PenaltyKind (..)+ , LambdaSelection (..)+ , selectLambdaCV+ , selectLambdaCVPure++ -- * Phase 31: CD 内部プリミティブの再利用 (RegularizedAdvanced 用)+ , softThreshold+ , cdLoop+ , mkRegFit+ , fitOLS+ , fitLasso+ ) where++import qualified Data.Vector as V+import qualified Data.Vector.Storable as VS+import qualified Data.Vector.Storable.Mutable as VSM+import qualified Numeric.LinearAlgebra as LA+import Control.Monad (forM_, when)+import Control.Monad.Primitive (PrimMonad, PrimState)+import Control.Monad.ST (runST)+import Data.List (foldl', sortBy)+import Data.Ord (comparing)+import Data.Word (Word32)+import System.IO.Unsafe (unsafePerformIO)+import qualified System.Random.MWC as MWC+import qualified Hanalyze.Stat.CV as HCV++-- ---------------------------------------------------------------------------+-- ペナルティ型+-- ---------------------------------------------------------------------------++-- | Regularization penalty.+data Penalty+ = NoPen -- ^ Ordinary OLS (@λ = 0@).+ | L2 Double -- ^ Ridge: @0.5 λ ‖β‖₂²@.+ | L1 Double -- ^ Lasso: @λ ‖β‖₁@.+ | ElasticNet Double Double -- ^ Elastic Net: @λ₁ ‖β‖₁ + 0.5 λ₂ ‖β‖₂²@.+ deriving (Show, Eq)++-- | Regularized-regression fit result.+data RegFit = RegFit+ { rfBeta :: LA.Vector Double+ , rfYHat :: LA.Vector Double+ , rfResid :: LA.Vector Double+ , rfR2 :: Double+ , rfPenalty :: Penalty+ , rfNonZero :: Int -- ^ Number of @|β_j| > 1e-8@ (Lasso sparsity).+ , rfIters :: Int -- ^ Iteration count (coordinate descent;+ -- 0 for closed-form solvers).+ } deriving (Show)++-- ---------------------------------------------------------------------------+-- メイン API+-- ---------------------------------------------------------------------------++-- | Single-output regularized-regression fit (sklearn-compatible+-- defaults @maxIter = 1000@, @tol = 1e-4@). Delegates to+-- 'fitRegularizedMulti' by promoting @y@ to a one-column matrix and+-- returns column 0 as a 'RegFit'.+fitRegularized :: Penalty -> LA.Matrix Double -> LA.Vector Double -> RegFit+fitRegularized pen x y =+ regFitFromMulti 0 (fitRegularizedMulti pen x (LA.asColumn y))++-- | Single-output regularized-regression fit with explicit convergence+-- controls (only meaningful for Lasso / Elastic Net).+fitRegularizedWith+ :: Int -> Double -> Penalty -> LA.Matrix Double -> LA.Vector Double+ -> RegFit+fitRegularizedWith maxIter tol pen x y =+ regFitFromMulti 0+ (fitRegularizedMultiWith maxIter tol pen x (LA.asColumn y))++-- | Single-output prediction.+predictRegularized :: RegFit -> LA.Matrix Double -> LA.Vector Double+predictRegularized fit xNew = xNew LA.#> rfBeta fit++-- ---------------------------------------------------------------------------+-- OLS (NoPen)+-- ---------------------------------------------------------------------------++-- | Plain ordinary-least-squares fit (no penalty).+fitOLS :: LA.Matrix Double -> LA.Vector Double -> RegFit+fitOLS x y =+ let beta = LA.flatten (x LA.<\> LA.asColumn y)+ yHat = x LA.#> beta+ r = y - yHat+ in mkRegFit beta yHat r y NoPen 0++-- ---------------------------------------------------------------------------+-- Ridge (closed form)+-- ---------------------------------------------------------------------------++-- | Ridge regression: @β = (XᵀX + λI)⁻¹ Xᵀy@.+fitRidge :: Double -> LA.Matrix Double -> LA.Vector Double -> RegFit+fitRidge lambda x y =+ let p = LA.cols x+ xtx = LA.tr x LA.<> x+ reg = xtx + LA.scale lambda (LA.ident p)+ xty = LA.tr x LA.#> y+ beta = LA.flatten (reg LA.<\> LA.asColumn xty)+ yHat = x LA.#> beta+ r = y - yHat+ in mkRegFit beta yHat r y (L2 lambda) 0++-- ---------------------------------------------------------------------------+-- Lasso (Coordinate Descent + Soft-thresholding)+-- ---------------------------------------------------------------------------++-- | Soft-threshold operator: @S(z, γ) = sign(z) × max(|z| − γ, 0)@.+softThreshold :: Double -> Double -> Double+softThreshold z gamma+ | z > gamma = z - gamma+ | z < -gamma = z + gamma+ | otherwise = 0++-- | Lasso regression: @β = argmin (1/2n) ‖y − Xβ‖² + λ ‖β‖₁@.+--+-- Solved by coordinate descent (one update per @β_j@):+--+-- @+-- r = y − X β+-- ρ_j = (1/n) X_jᵀ r + β_j × (1/n) ‖X_j‖²+-- β_j ← S(ρ_j, λ) / ((1/n) ‖X_j‖²)+-- @+fitLasso :: Double -- ^ Penalty @λ@.+ -> LA.Matrix Double -- ^ Design matrix @X@.+ -> LA.Vector Double -- ^ Response @y@.+ -> Int -- ^ Maximum CD iterations.+ -> Double -- ^ Convergence tolerance.+ -> RegFit+fitLasso lambda x y maxIter tol =+ let (betaFinal, iters) = cdLoop x y maxIter tol+ (\rho cSq -> softThreshold rho lambda / cSq)+ yHat = x LA.#> betaFinal+ r = y - yHat+ in mkRegFit betaFinal yHat r y (L1 lambda) iters++-- ---------------------------------------------------------------------------+-- Elastic Net (Coordinate Descent)+-- ---------------------------------------------------------------------------++-- | Elastic-Net regression:+-- @β = argmin (1/2n) ‖y − Xβ‖² + λ₁ ‖β‖₁ + 0.5 λ₂ ‖β‖²@.+--+-- Coordinate descent update:+-- @β_j ← S(ρ_j, λ₁) / ((1/n) ‖X_j‖² + λ₂)@.+fitElasticNet :: Double -> Double -> LA.Matrix Double -> LA.Vector Double+ -> Int -> Double -> RegFit+fitElasticNet lambda1 lambda2 x y maxIter tol =+ let (betaFinal, iters) = cdLoop x y maxIter tol+ (\rho cSq -> softThreshold rho lambda1+ / (cSq + lambda2))+ yHat = x LA.#> betaFinal+ r = y - yHat+ in mkRegFit betaFinal yHat r y (ElasticNet lambda1 lambda2) iters++-- ---------------------------------------------------------------------------+-- Shared CD loop with incremental residual maintenance+-- ---------------------------------------------------------------------------++-- | Coordinate descent loop shared by 'fitLasso' and 'fitElasticNet'.+--+-- The caller supplies a /closed-form coordinate update/ @upd ρ_j cSq_j@+-- that returns @β_j_new@ given the partial-residual correlation @ρ_j@+-- and the column-norm @cSq_j = ‖X_j‖²/n@.+--+-- Implementation (R2): the inner sweep runs in 'IO' on+-- 'Data.Vector.Storable.Mutable' buffers. Both @β@ and the residual+-- @r = y − Xβ@ are updated in place, and the columns of @X@ are looked+-- up through a boxed 'Data.Vector.Vector' for @O(1)@ indexing (the+-- previous list-based @cols !! j@ paid @O(p)@ per coordinate). This is+-- the moral equivalent of sklearn's Cython coordinate-descent inner+-- loop; the user-visible behaviour is identical to the prior Vector+-- implementation up to floating-point rounding.+cdLoop+ :: LA.Matrix Double -- X (n × p)+ -> LA.Vector Double -- y+ -> Int -- max iterations+ -> Double -- tolerance on |Δβ|₂+ -> (Double -> Double -> Double) -- (ρ, cSq) → β_j_new+ -> (LA.Vector Double, Int)+cdLoop x y maxIter tol upd+ | LA.rows x >= 4 * LA.cols x =+ cdLoopGram x y maxIter tol upd -- n ≫ p: Gram precompute+ | otherwise = cdLoopResidual x y maxIter tol upd++-- | Coordinate descent maintaining the @n@-dimensional residual+-- @r = y − Xβ@. Best when @n@ is small (the residual update is+-- @O(n)@ per coord; the alternative 'cdLoopGram' keeps a length-@p@+-- prediction vector and pays @O(p)@ per coord).+cdLoopResidual+ :: LA.Matrix Double -> LA.Vector Double -> Int -> Double+ -> (Double -> Double -> Double)+ -> (LA.Vector Double, Int)+cdLoopResidual x y maxIter tol upd = unsafePerformIO $ do+ let nRows = LA.rows x+ n = fromIntegral nRows :: Double+ p = LA.cols x+ colsB = V.fromList (LA.toColumns x) -- O(1) indexing+ -- F1: per-column squared sum via 1 GEMV instead of p+ -- 'sumElements (c*c)' calls. ones_n^T (X⊙X) gives length-p+ -- vector of column sums; divide by n.+ onesN = LA.konst 1 nRows :: LA.Vector Double+ colSqN = LA.scale (1 / n) (onesN LA.<# (x * x))++ -- Mutable buffer for β (single-index updates each coordinate step).+ bMut <- VS.thaw (LA.konst 0 p :: LA.Vector Double)++ -- The residual r is kept as an /immutable/ 'LA.Vector Double' between+ -- coordinate updates so that @r ← r − d · x_j@ can use BLAS axpy+ -- (a single optimized call) rather than a per-element Haskell loop.+ let sweep r = do+ beforeSnap <- VS.freeze bMut+ let stepCoord rCur j = do+ let xj = colsB V.! j+ cSq = colSqN `LA.atIndex` j+ bjOld <- VSM.unsafeRead bMut j+ let rho = (xj LA.<.> rCur) / n + bjOld * cSq+ bjNew = upd rho cSq+ d = bjNew - bjOld+ if d == 0+ then return rCur+ else do+ VSM.unsafeWrite bMut j bjNew+ -- BLAS axpy: r' = r - d * x_j. Tried fusing via+ -- 'VS.zipWith' (one alloc instead of two) but it+ -- was 1.6× slower — hmatrix's @(-)@ + @LA.scale@+ -- chain dispatches to BLAS @daxpy@/@dscal@ which+ -- are SIMD-vectorised at the C level, beating any+ -- pure Haskell per-element loop on n ≥ 1000.+ return (rCur - LA.scale d xj)+ rEnd <- foldM' stepCoord r [0 .. p - 1]+ afterSnap <- VS.freeze bMut+ return (beforeSnap, afterSnap, rEnd)++ let go k r = do+ if k >= maxIter+ then return k+ else do+ (before, after, r') <- sweep r+ let diff = LA.norm_2 (after - before)+ if diff < tol then return (k + 1) else go (k + 1) r'++ iters <- go 0 y -- initial residual = y (since β₀ = 0)+ betaFinal <- VS.freeze bMut+ return (betaFinal, iters)+ where+ -- Strict foldM that discards no intermediate results (folds an+ -- accumulator @r@ through @f@).+ foldM' :: Monad m => (b -> a -> m b) -> b -> [a] -> m b+ foldM' _ acc [] = return acc+ foldM' f acc (z:zs) = do+ acc' <- f acc z+ acc' `seq` foldM' f acc' zs++-- | Coordinate descent with /precomputed/ Gram matrix+-- @G = XᵀX@ (p × p) and @v = Xᵀy@ (length p).+--+-- For @n ≫ p@ this is dramatically faster than 'cdLoopResidual'+-- because each coordinate update touches a length-@p@ prediction+-- vector @q = G β@ rather than the length-@n@ residual. With+-- @n = 10000, p = 50@ the per-coord work goes from @O(n)@ to+-- @O(p)@ — roughly 200× less arithmetic per inner step. Mirrors+-- sklearn's @Lasso(precompute=True)@.+--+-- Setup cost: forming @G@ is @O(np²)@ (one BLAS GEMM /+-- @LA.tr x \<\> x@); for the @p × p = 50 × 50@ Gram matrix at+-- @n = 10k@ that's ~25 million flops, amortised over the inner+-- coordinate-descent sweeps.+cdLoopGram+ :: LA.Matrix Double -> LA.Vector Double -> Int -> Double+ -> (Double -> Double -> Double)+ -> (LA.Vector Double, Int)+cdLoopGram x y maxIter tol upd = unsafePerformIO $ do+ let nRows = LA.rows x+ nD = fromIntegral nRows :: Double+ p = LA.cols x+ gMat = LA.tr x LA.<> x -- p × p (SPD)+ vVec = LA.tr x LA.#> y -- length p+ diagG = LA.takeDiag gMat -- length p (= ‖X_j‖²)+ -- Per-column views of @G@ for the @q = G β@ rank-1 update.+ gCols = V.fromList (LA.toColumns gMat) -- O(1) column access++ bMut <- VS.thaw (LA.konst 0 p :: LA.Vector Double)+ -- @q[k] = (G β)[k]@. Maintained incrementally: a coord update+ -- @β_j ← β_j + d@ shifts @q ← q + d · G[:, j]@.+ qMut <- VS.thaw (LA.konst 0 p :: LA.Vector Double)++ let stepCoord !maxDelta j = do+ bjOld <- VSM.unsafeRead bMut j+ qj <- VSM.unsafeRead qMut j+ let !cSq = (diagG `LA.atIndex` j) / nD+ -- ρ_j = (X_jᵀ r) / n + β_j cSq, where+ -- X_jᵀ r = X_jᵀ y − X_jᵀ X β = v_j − q_j (linear in β)+ !rho = (vVec `LA.atIndex` j - qj) / nD + bjOld * cSq+ !bjNew = upd rho cSq+ !d = bjNew - bjOld+ !ad = abs d+ !newMax = if ad > maxDelta then ad else maxDelta+ if d == 0+ then return newMax+ else do+ VSM.unsafeWrite bMut j bjNew+ -- BLAS axpy on @q@: @q ← q + d · G[:, j]@ via a short+ -- mutable loop (p elements; for typical p ≤ 100 the+ -- BLAS dispatch overhead would dominate).+ let gCol = gCols V.! j+ let go !k+ | k >= p = pure ()+ | otherwise = do+ qk <- VSM.unsafeRead qMut k+ VSM.unsafeWrite qMut k+ (qk + d * (gCol `VS.unsafeIndex` k))+ go (k + 1)+ go 0+ return newMax++ let sweep = do+ let go !mx !j+ | j >= p = pure mx+ | otherwise = do+ mx' <- stepCoord mx j+ go mx' (j + 1)+ go 0 0++ let loop !k = do+ if k >= maxIter+ then return k+ else do+ mxDelta <- sweep+ -- Convergence on max |Δβ_j| (sklearn's default test).+ -- Avoids the per-sweep @before/after freeze + norm_2@ that+ -- 'cdLoopResidual' performs.+ if mxDelta < tol then return (k + 1) else loop (k + 1)++ iters <- loop 0+ betaFinal <- VS.freeze bMut+ return (betaFinal, iters)++-- ---------------------------------------------------------------------------+-- 共通ヘルパ+-- ---------------------------------------------------------------------------++mkRegFit :: LA.Vector Double -> LA.Vector Double -> LA.Vector Double+ -> LA.Vector Double -> Penalty -> Int -> RegFit+mkRegFit beta yHat r y pen iters =+ let mu = LA.sumElements y / fromIntegral (LA.size y)+ ssT = LA.sumElements ((y - LA.scalar mu) ^ (2 :: Int))+ ssR = LA.sumElements (r ^ (2 :: Int))+ r2 = if ssT == 0 then 0 else 1 - ssR / ssT+ nz = length [v | v <- LA.toList beta, abs v > 1e-8]+ in RegFit beta yHat r r2 pen nz iters++-- ---------------------------------------------------------------------------+-- Standardization+-- ---------------------------------------------------------------------------++-- | Standardize each column to mean 0 and standard deviation 1.+--+-- Returns @(X_std, column means, column sds)@. The transformation is+-- @X_std = (X − μ) / σ@; use 'unstandardizeBeta' to map coefficients+-- back to the original scale.+standardize :: LA.Matrix Double+ -> (LA.Matrix Double, V.Vector Double, V.Vector Double)+standardize x =+ let n = LA.rows x+ p = LA.cols x+ means = V.fromList+ [ LA.sumElements (LA.flatten (x LA.¿ [j])) / fromIntegral n+ | j <- [0 .. p - 1] ]+ sds = V.fromList+ [ let c = LA.flatten (x LA.¿ [j])+ mu = means V.! j+ var = LA.sumElements ((c - LA.scalar mu) ^ (2 :: Int))+ / fromIntegral (n - 1)+ in sqrt var+ | j <- [0 .. p - 1] ]+ cols' = [ let c = LA.flatten (x LA.¿ [j])+ mu = means V.! j+ sd = sds V.! j+ in (c - LA.scalar mu) / LA.scalar (if sd == 0 then 1 else sd)+ | j <- [0 .. p - 1] ]+ xStd = LA.fromColumns cols'+ in (xStd, means, sds)++-- | Map coefficients fitted in standardized space back to the original+-- scale: @β_orig_j = β_std_j / σ_j@. The intercept must be adjusted+-- separately, outside this helper.+unstandardizeBeta :: V.Vector Double -> LA.Vector Double -> LA.Vector Double+unstandardizeBeta sds betaStd =+ let p = LA.size betaStd+ in LA.fromList+ [ (betaStd `LA.atIndex` j) / (sds V.! j)+ | j <- [0 .. p - 1] ]++-- ---------------------------------------------------------------------------+-- 多出力対応 (主 API)+-- ---------------------------------------------------------------------------++-- | [日本語]: 多出力正則化回帰の fit 結果。+-- Y は n × q、係数 B は p × q、予測 Ŷ = X B。+-- 'rfmFits' は列ごとの単出力 'RegFit' (R²、|β|>0 の数、反復回数を提供)。+-- [English]: Multi-output regularized-regression fit result.+-- Y is n x q, the coefficients B are p x q, and predictions are+-- Ŷ = X B. 'rfmFits' holds the per-column single-output 'RegFit'+-- (provides R², the count of |β|>0, and the iteration count).+data RegFitMulti = RegFitMulti+ { rfmFits :: [RegFit] -- ^ [日本語]: 列ごとの単出力 fit。 [English]: Per-column single-output fit.+ , rfmBeta :: LA.Matrix Double -- ^ p × q+ , rfmYHat :: LA.Matrix Double -- ^ n × q+ , rfmResid :: LA.Matrix Double -- ^ n × q+ , rfmR2 :: [Double] -- ^ [日本語]: 列ごとの R²。 [English]: Per-column R².+ , rfmPenalty :: Penalty+ } deriving (Show)++-- | [日本語]: sklearn 互換の既定収束パラメータ (@maxIter = 1000@,+-- @tol = 1e-4@) を使う多出力正則化回帰。 上書きするには+-- 'fitRegularizedMultiWith' を使う。+--+-- - OLS / Ridge: 行列形式 1 回の線形求解で全 q 列を一括処理 (高速)。+-- - Lasso / Elastic Net: 列ごと座標降下 (列間に依存なし、独立並列可)。+--+-- [English]: Multi-output regularized regression with sklearn-compatible+-- default convergence parameters (@maxIter = 1000@, @tol = 1e-4@). Use+-- 'fitRegularizedMultiWith' to override.+--+-- - OLS / Ridge: all q columns are handled in one batch via a single+-- matrix-form linear solve (fast).+-- - Lasso / Elastic Net: per-column coordinate descent (no dependency+-- between columns, so independently parallelizable).+fitRegularizedMulti :: Penalty -> LA.Matrix Double -> LA.Matrix Double+ -> RegFitMulti+fitRegularizedMulti = fitRegularizedMultiWith 1000 1e-4++-- | Multi-output regularized regression with explicit convergence+-- controls (@maxIter@, @tol@). Affects only Lasso / Elastic Net (the+-- iterative coordinate-descent paths). OLS / Ridge are direct solves+-- and ignore these parameters.+fitRegularizedMultiWith+ :: Int -- ^ Maximum CD iterations (default 1000).+ -> Double -- ^ Convergence tolerance @|Δβ|₂@ (default 1e-4).+ -> Penalty+ -> LA.Matrix Double -> LA.Matrix Double+ -> RegFitMulti+fitRegularizedMultiWith maxIter tol pen x y = case pen of+ NoPen -> fitOLSMulti x y+ L2 lambda -> fitRidgeMulti lambda x y+ L1 lambda -> fitColumnwise (fitLasso lambda) maxIter tol pen x y+ ElasticNet l1 l2 -> fitColumnwise (fitElasticNet l1 l2) maxIter tol pen x y++-- | Multi-output prediction.+predictRegularizedMulti :: RegFitMulti -> LA.Matrix Double -> LA.Matrix Double+predictRegularizedMulti mf xNew = xNew LA.<> rfmBeta mf++-- | Extract column @j@ of a 'RegFitMulti' as a 'RegFit'.+regFitFromMulti :: Int -> RegFitMulti -> RegFit+regFitFromMulti j mf+ | j < length (rfmFits mf) = rfmFits mf !! j+ | otherwise = error ("regFitFromMulti: column " ++ show j ++ " out of range")++-- | Matrix-form OLS: @B = X \\ Y@ in a single LAPACK call.+fitOLSMulti :: LA.Matrix Double -> LA.Matrix Double -> RegFitMulti+fitOLSMulti x y =+ let beta = x LA.<\> y+ in mkRegFitMulti beta x y NoPen (replicate (LA.cols y) 0)++-- | [日本語]: 行列形式の Ridge: B = (XᵀX + λI)⁻¹ XᵀY (1 回の Cholesky/LU)。+-- [English]: Matrix-form Ridge: B = (XᵀX + λI)⁻¹ XᵀY (a single+-- Cholesky/LU solve).+fitRidgeMulti :: Double -> LA.Matrix Double -> LA.Matrix Double -> RegFitMulti+fitRidgeMulti lambda x y =+ let p = LA.cols x+ reg = LA.tr x LA.<> x + LA.scale lambda (LA.ident p)+ xty = LA.tr x LA.<> y+ beta = reg LA.<\> xty+ in mkRegFitMulti beta x y (L2 lambda) (replicate (LA.cols y) 0)++-- | [日本語]: 列ごと CD (Lasso / Elastic Net 用)。+--+-- @maxIter@ / @tol@ は呼び元から指定する (旧版は 1000 / 1e-7 を+-- hardcoded していたが、 これは sklearn の規定値 1000 / 1e-4 より tol+-- 側が 1000× 厳しく、 bench 比較が不公平だったため明示パラメタ化)。+--+-- [English]: Per-column CD (for Lasso / Elastic Net).+--+-- @maxIter@ / @tol@ are supplied by the caller (an earlier version+-- hardcoded 1000 / 1e-7, but that tol was 1000x stricter than+-- sklearn's default of 1000 / 1e-4, making bench comparisons unfair;+-- hence explicit parameterization).+fitColumnwise+ :: (LA.Matrix Double -> LA.Vector Double -> Int -> Double -> RegFit)+ -> Int -- ^ @maxIter@+ -> Double -- ^ @tol@+ -> Penalty+ -> LA.Matrix Double -> LA.Matrix Double+ -> RegFitMulti+fitColumnwise fitCol maxIter tol pen x y =+ let q = LA.cols y+ fits = [ fitCol x (LA.flatten (y LA.¿ [j])) maxIter tol+ | j <- [0 .. q - 1] ]+ bMat = LA.fromColumns [rfBeta f | f <- fits]+ yHat = LA.fromColumns [rfYHat f | f <- fits]+ res = LA.fromColumns [rfResid f | f <- fits]+ r2s = [rfR2 f | f <- fits]+ in RegFitMulti fits bMat yHat res r2s pen++-- | [日本語]: 共通: B 行列から RegFitMulti を組み立て。各列の R² と非零係数数も計算。+-- [English]: Shared: assembles a RegFitMulti from the B matrix. Also+-- computes each column's R² and nonzero-coefficient count.+mkRegFitMulti :: LA.Matrix Double -> LA.Matrix Double -> LA.Matrix Double+ -> Penalty -> [Int] -> RegFitMulti+mkRegFitMulti beta x y pen iters =+ let yHat = x LA.<> beta+ res = y - yHat+ q = LA.cols y+ colFit j =+ let b = LA.flatten (beta LA.¿ [j])+ yh = LA.flatten (yHat LA.¿ [j])+ rj = LA.flatten (res LA.¿ [j])+ yj = LA.flatten (y LA.¿ [j])+ in mkRegFit b yh rj yj pen (iters !! j)+ fits = [colFit j | j <- [0 .. q - 1]]+ in RegFitMulti fits beta yHat res [rfR2 f | f <- fits] pen++-- ---------------------------------------------------------------------------+-- Regularization path+-- ---------------------------------------------------------------------------++-- | [日本語]: 与えられた λ の系列に対して係数推移を計算する+-- (regularization path)。 戻り値: 各 λ に対する係数ベクトル。+--+-- 利用例 (Ridge):+--+-- @+-- let lams = [10 ** (-4 + 0.1 * i) | i <- [0..60]]+-- path = regularizationPath L2 lams xMat yVec+-- -- path :: [(Double, [Double])] -- (λ, [β₀, β₁, ...])+-- @+--+-- [English]: Computes the coefficient trajectory+-- (regularization path) over a given sequence of λ. Returns the+-- coefficient vector for each λ.+--+-- Usage example (Ridge):+--+-- @+-- let lams = [10 ** (-4 + 0.1 * i) | i <- [0..60]]+-- path = regularizationPath L2 lams xMat yVec+-- -- path :: [(Double, [Double])] -- (λ, [β₀, β₁, ...])+-- @+regularizationPath+ :: (Double -> Penalty) -- ^ λ → Penalty (e.g. @L2@, @L1@,+ -- @\\l -> ElasticNet (l*α) (l*(1-α))@)+ -> [Double] -- ^ [日本語]: λ 系列。 [English]: The λ sequence.+ -> LA.Matrix Double -- ^ [日本語]: X (intercept 列付き)。 [English]: X (with an intercept column).+ -> LA.Vector Double -- ^ y+ -> [(Double, [Double])] -- ^ [日本語]: [(λ, 係数ベクトル)]。 [English]: [(λ, coefficient vector)].+regularizationPath mkPen lambdas x y =+ [ (lam, LA.toList (rfBeta (fitRegularized (mkPen lam) x y)))+ | lam <- lambdas ]+++-- ===========================================================================+-- λ 自動選択 (Phase 4.4、 request/150)+-- ===========================================================================++-- | [日本語]: Penalty の "形" (λ 抜き)。 'selectLambdaCV' の grid 探索で λ を+-- 変化させる際の penalty family を指定する。+-- [English]: The "shape" of a Penalty (without λ). Specifies the+-- penalty family to vary λ over during 'selectLambdaCV''s grid search.+data PenaltyKind+ = KindRidge -- ^ Ridge (= 'L2' λ)+ | KindLasso -- ^ Lasso (= 'L1' λ)+ | KindElasticNet !Double -- ^ [日本語]: ElasticNet。 @α@ = L1 比率 (0 ≤ α ≤ 1)。+ -- total penalty = λ·(α·L1 + (1-α)/2·L2)、 内部で+ -- 'ElasticNet' (α·λ) ((1-α)·λ) に展開。+ -- [English]: ElasticNet. @α@ = L1 ratio+ -- (0 ≤ α ≤ 1). total penalty =+ -- λ·(α·L1 + (1-α)/2·L2), expanded+ -- internally into 'ElasticNet' (α·λ)+ -- ((1-α)·λ).+ deriving (Show, Eq)++-- | [日本語]: λ 自動選択の結果。+-- [English]: The result of automatic λ selection.+data LambdaSelection = LambdaSelection+ { lsBestLambda :: !Double -- ^ [日本語]: CV MSE が最小の λ。 [English]: The λ with the smallest CV MSE.+ , lsLambdas :: ![Double] -- ^ [日本語]: 検証した λ 値 (入力順)。 [English]: The λ values tested (in input order).+ , lsCVScores :: ![Double] -- ^ [日本語]: 各 λ の CV MSE (lsLambdas と対応)。 [English]: Each λ's CV MSE (corresponds to lsLambdas).+ , lsCVScoreSE :: ![Double] -- ^ [日本語]: 各 λ の CV MSE の標準誤差 (fold 間 SD)。 [English]: The standard error of each λ's CV MSE (SD across folds).+ , lsOneSeLambda :: !Double -- ^ [日本語]: 1-SE rule の λ (best ± 1·SE 範囲内で+ -- 最大スパース = 最大 λ)。+ -- [English]: The λ from the 1-SE rule+ -- (the largest λ, i.e. the sparsest,+ -- within best ± 1 SE).+ , lsKind :: !PenaltyKind -- ^ [日本語]: 入力 PenaltyKind を保持 (canvas 側参照用)。 [English]: Retains the input PenaltyKind (for reference by the canvas side).+ } deriving (Show)++-- | [日本語]: k-fold CV で λ を自動選択。+--+-- 入力 'PenaltyKind' に従って λ grid を Ridge/Lasso/EN の 'Penalty' に展開し、+-- 各 λ について k-fold CV を実行、 fold 平均 MSE を計算する。+--+-- 返り値の 'lsBestLambda' は MSE 最小の λ、 'lsOneSeLambda' は 1-SE rule+-- (= best MSE から 1·SE 以内で最大スパースな λ) の λ。+--+-- [English]: Automatically selects λ via k-fold CV.+--+-- Expands the λ grid into Ridge\/Lasso\/EN 'Penalty' values according+-- to the input 'PenaltyKind', runs k-fold CV for each λ, and computes+-- the fold-averaged MSE.+--+-- The returned 'lsBestLambda' is the λ with the smallest MSE;+-- 'lsOneSeLambda' is the λ from the 1-SE rule (the sparsest λ within+-- 1 SE of the best MSE).+selectLambdaCV+ :: PrimMonad m+ => Int -- ^ [日本語]: k-fold の k (≥ 2)。 [English]: The k of k-fold (≥ 2).+ -> PenaltyKind -- ^ Ridge / Lasso / ElasticNet+ -> [Double] -- ^ [日本語]: 検証する λ grid (log-spaced 推奨)。 [English]: The λ grid to validate (log-spaced recommended).+ -> LA.Matrix Double -- ^ X (n × p)+ -> LA.Vector Double -- ^ y (n)+ -> MWC.Gen (PrimState m) -- ^ [日本語]: shuffle 用 (ST/IO 両用)。 [English]: For shuffling (works with both ST/IO).+ -> m LambdaSelection+selectLambdaCV k kind lambdas xMat yVec gen = do+ let n = LA.rows xMat+ folds <- HCV.kFold k n gen+ let perLambda lam =+ let scores =+ [ mseForFold (penaltyOf kind lam) xMat yVec trainIdx testIdx+ | (trainIdx, testIdx) <- folds, not (null testIdx)+ ]+ !nFolds = fromIntegral (length scores) :: Double+ mean = sum scores / nFolds+ varN = sum [(s - mean) ** 2 | s <- scores] / max 1 (nFolds - 1)+ !se = sqrt (varN / nFolds)+ in (mean, se)+ stats = map perLambda lambdas+ mses = map fst stats+ ses = map snd stats+ indexedMSEs = zip3 lambdas mses ses+ sortedAsc = sortBy (comparing (\(_, m, _) -> m)) indexedMSEs+ (bestL, bestMSE, bestSE) =+ case sortedAsc of+ (h:_) -> h+ [] -> (0, 0, 0)+ threshold = bestMSE + bestSE+ -- 1-SE λ: best から 1·SE 以内の λ のうち最大 (= 最大スパース)+ oneSe =+ let cands = [ lam | (lam, m, _) <- indexedMSEs, m <= threshold ]+ in if null cands then bestL else maximum cands+ pure LambdaSelection+ { lsBestLambda = bestL+ , lsLambdas = lambdas+ , lsCVScores = mses+ , lsOneSeLambda = oneSe+ , lsCVScoreSE = ses+ , lsKind = kind+ }++-- | [日本語]: 純粋 (seed) 版 'selectLambdaCV'。 同 seed → 同 λ 選択 (ST/IO+-- ビット一致)。 罰則回帰の高レベル spec (`df |-> lasso …`) を pure+-- @fitWith@ で完結させる継ぎ目 (GP の @AutoCV@ / @kMeansPure@ /+-- @fitRFVPure@ と一貫)。+-- [English]: Pure (seed) variant of 'selectLambdaCV'. Same seed →+-- same λ selection (bit-identical across ST/IO). This is the seam+-- that lets the high-level spec for regularized regression+-- (`df |-> lasso …`) be completed with a pure @fitWith@ (consistent+-- with GP's @AutoCV@ \/ @kMeansPure@ \/ @fitRFVPure@).+selectLambdaCVPure+ :: Int -> PenaltyKind -> [Double] -> LA.Matrix Double -> LA.Vector Double+ -> Word32 -> LambdaSelection+selectLambdaCVPure k kind lambdas xMat yVec seed =+ runST (MWC.initialize (V.singleton seed) >>= selectLambdaCV k kind lambdas xMat yVec)++-- | [日本語]: 内部 helper: PenaltyKind と λ から具体 'Penalty' を組み立てる。+-- [English]: Internal helper: builds a concrete 'Penalty' from a+-- PenaltyKind and λ.+penaltyOf :: PenaltyKind -> Double -> Penalty+penaltyOf KindRidge lam = L2 lam+penaltyOf KindLasso lam = L1 lam+penaltyOf (KindElasticNet a) lam = ElasticNet (a * lam) ((1 - a) * lam)++-- | [日本語]: 1 fold の MSE を返す。 train index で fit、 test index で+-- predict + 残差²平均。+-- [English]: Returns the MSE for one fold. Fits on the train index,+-- predicts on the test index, then averages the squared residuals.+mseForFold+ :: Penalty+ -> LA.Matrix Double+ -> LA.Vector Double+ -> [Int] -- train 行 index+ -> [Int] -- test 行 index+ -> Double+mseForFold pen xMat yVec trainIdx testIdx =+ let xTr = xMat LA.? trainIdx+ yTr = LA.fromList [ yVec LA.! i | i <- trainIdx ]+ xTe = xMat LA.? testIdx+ yTe = LA.fromList [ yVec LA.! i | i <- testIdx ]+ fit = fitRegularized pen xTr yTr+ yHat = predictRegularized fit xTe+ resid = yTe - yHat+ nTe = fromIntegral (length testIdx) :: Double+ in LA.sumElements (resid * resid) / nTe
+ src/Hanalyze/Model/RegularizedAdvanced.hs view
@@ -0,0 +1,376 @@+-- |+-- Module : Hanalyze.Model.RegularizedAdvanced+-- Description : 高度な罰則項回帰 — Adaptive Lasso / MCP / SCAD / Group Lasso+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- [日本語]: 高度な罰則項回帰: Adaptive Lasso / MCP / SCAD / Group Lasso。+--+-- 既存 'Hanalyze.Model.Regularized' (Lasso/Ridge/Elastic Net + CV λ 選択)+-- を補完する変数選択型の罰則項群。 JMP "Generalized+-- Regression" platform / R @ncvreg@ / @grpreg@ / @glmnet@ (adaptive オプション)+-- 相当。+--+-- ## 共通の前提+--+-- - 罰則項は Lasso 同様 X の列スケールに敏感。 呼び出し側で+-- 'Hanalyze.Model.Regularized.standardize' しておく+-- - 内部 CD は 'Hanalyze.Model.Regularized.cdLoop' を流用 (Adaptive Lasso は+-- 列再重み付け、 MCP / SCAD は per-coord non-convex threshold)+-- - Group Lasso は block CD で別ループ (Yuan-Lin 2006 algorithm)+--+-- Reference:+-- Zou (2006), Zhang (2010), Fan-Li (2001), Yuan-Lin (2006),+-- Breheny-Huang (2011) "Coordinate descent algorithms for non-convex+-- penalized regression". Ann. Appl. Stat. 5:232-253.+--+-- [English]: Advanced penalized regression: Adaptive Lasso \/ MCP \/ SCAD \/+-- Group Lasso.+--+-- A family of variable-selection penalties that complements the existing+-- 'Hanalyze.Model.Regularized' (Lasso\/Ridge\/Elastic Net + CV λ+-- selection). Equivalent to JMP's "Generalized Regression" platform \/ R's+-- @ncvreg@ \/ @grpreg@ \/ @glmnet@ (adaptive option).+--+-- ## Shared assumptions+--+-- - Like Lasso, these penalties are sensitive to the column scale of X.+-- Callers should apply 'Hanalyze.Model.Regularized.standardize'+-- first+-- - The inner CD reuses 'Hanalyze.Model.Regularized.cdLoop' (Adaptive+-- Lasso via column reweighting, MCP \/ SCAD via per-coord non-convex+-- thresholding)+-- - Group Lasso uses a separate loop with block CD (Yuan-Lin 2006 algorithm)+--+-- Reference:+-- Zou (2006), Zhang (2010), Fan-Li (2001), Yuan-Lin (2006),+-- Breheny-Huang (2011) "Coordinate descent algorithms for non-convex+-- penalized regression". Ann. Appl. Stat. 5:232-253.+module Hanalyze.Model.RegularizedAdvanced+ ( -- * Adaptive Lasso (Zou 2006)+ fitAdaptiveLasso+ , adaptiveWeightsFromOLS+ -- * MCP (Zhang 2010)+ , fitMCP+ -- * SCAD (Fan-Li 2001)+ , fitSCAD+ -- * Group Lasso (Yuan-Lin 2006)+ , fitGroupLasso+ ) where++import qualified Numeric.LinearAlgebra as LA+import Hanalyze.Model.Regularized+ (RegFit (..), Penalty (..), softThreshold, cdLoop,+ mkRegFit, fitOLS, fitLasso)++-- ---------------------------------------------------------------------------+-- 31-A1: Adaptive Lasso+-- ---------------------------------------------------------------------------++-- | [日本語]: Adaptive Lasso (Zou 2006): @argmin (1/2n)|y - Xβ|² + λ Σ w_j |β_j|@。+--+-- 解法: column reweighting trick — @x_j' = x_j / w_j@ で変形すると標準+-- Lasso になり、 解 @β_j' = β_j · w_j@ から @β_j = β_j' / w_j@ で復元できる。+-- 既存 'fitLasso' をそのまま流用するので追加 CD ループ不要。+--+-- @w_j@ は典型的に OLS pilot 推定値から構築する ('adaptiveWeightsFromOLS')。+--+-- 注意: @w_j = 0@ は "罰則ゼロ" ではなく実装上 "@β_j = 0@ 強制" として扱う+-- (列 j を 0 vector に潰すため)。 罰則ゼロにしたい場合は @w_j@ を非常に+-- 小さい正値にする。+--+-- [English]: Adaptive Lasso (Zou 2006): @argmin (1/2n)|y - Xβ|² + λ Σ w_j |β_j|@.+--+-- Solved via the column reweighting trick — transforming with+-- @x_j' = x_j / w_j@ turns it into standard Lasso, and the original-space+-- solution @β_j = β_j' / w_j@ is recovered from @β_j' = β_j · w_j@. Since+-- this reuses the existing 'fitLasso' directly, no extra CD loop is needed.+--+-- @w_j@ is typically built from an OLS pilot estimate ('adaptiveWeightsFromOLS').+--+-- Note: @w_j = 0@ is not treated as "zero penalty" but rather, in this+-- implementation, as "force @β_j = 0@" (since it collapses column j to a+-- zero vector). To get a truly zero penalty, use a very small positive+-- value for @w_j@ instead.+fitAdaptiveLasso+ :: Double -- ^ @λ@+ -> LA.Vector Double -- ^ [日本語]: weights @w@ (length @p@、 全 @≥ 0@)。 [English]: weights @w@ (length @p@, all @≥ 0@).+ -> LA.Matrix Double -- ^ X (n × p)+ -> LA.Vector Double -- ^ y+ -> Int -- ^ max CD iterations+ -> Double -- ^ tolerance+ -> RegFit+fitAdaptiveLasso lambda w x y maxIter tol =+ let invW = LA.cmap (\wj -> if wj <= 0 then 0 else 1 / wj) w+ xRew = x LA.<> LA.diag invW+ lassoF = fitLasso lambda xRew y maxIter tol+ -- 変形空間の解 β' を元の空間の β = β' / w に戻す+ betaP = rfBeta lassoF+ beta = invW * betaP+ yHat = x LA.#> beta+ r = y - yHat+ in mkRegFit beta yHat r y (L1 lambda) (rfIters lassoF)++-- | [日本語]: OLS pilot 推定値から Adaptive Lasso 重み @w_j = 1 / |β̂_j^OLS|^γ@ を構築。+-- 典型値 @γ = 1@。 OLS が定義できないケース (@n < p@) では事前に Ridge pilot+-- に切り替えるなど呼び出し側で工夫する。 0 除算回避のため @|β̂| ≤ 1e-8@ の+-- 場合は floor @1e-8@ を使う。+--+-- [English]: Builds Adaptive Lasso weights @w_j = 1 / |β̂_j^OLS|^γ@ from an+-- OLS pilot estimate. Typical value @γ = 1@. When OLS is undefined+-- (@n < p@), it's up to the caller to work around it, e.g. by switching to+-- a Ridge pilot beforehand. To avoid division by zero, a floor of @1e-8@ is+-- applied when @|β̂| ≤ 1e-8@.+adaptiveWeightsFromOLS+ :: Double -- ^ @γ@ (typical 1.0)+ -> LA.Matrix Double -> LA.Vector Double -> LA.Vector Double+adaptiveWeightsFromOLS gamma x y =+ let beta0 = rfBeta (fitOLS x y)+ in LA.cmap (\b -> 1 / (max 1e-8 (abs b) ** gamma)) beta0++-- ---------------------------------------------------------------------------+-- 31-A2: MCP (Minimax Concave Penalty、 Zhang 2010)+-- ---------------------------------------------------------------------------++-- | [日本語]: MCP non-convex 罰則:+--+-- @+-- p_{λ,γ}(β) = λ |β| - β²/(2γ) if |β| ≤ γλ+-- = γλ²/2 if |β| > γλ+-- @+--+-- @γ → ∞@ で Lasso に縮退、 @γ → 1@ で hard-threshold 寄りになる。 典型値+-- @γ ∈ [2, 5]@。+--+-- Coordinate descent 更新 (Breheny-Huang 2011, with column-norm @cSq@):+--+-- @+-- z = ρ_j+-- β_j = S(z, λ) / (cSq - 1/γ) if |z| ≤ γλ·cSq+-- = z / cSq if |z| > γλ·cSq+-- @+--+-- 前提: @cSq > 1/γ@ (= 罰則項の凹性を局所凸性が上回る)。 標準化 @X@ (cSq ≈ 1)+-- で @γ > 1@ なら自動的に満たす。 違反時は inner CD が発散する可能性があり、+-- 呼び出し側で @standardize@ + @γ ≥ 3@ を推奨。+--+-- [English]: MCP non-convex penalty:+--+-- @+-- p_{λ,γ}(β) = λ |β| - β²/(2γ) if |β| ≤ γλ+-- = γλ²/2 if |β| > γλ+-- @+--+-- As @γ → ∞@ it degenerates to Lasso; as @γ → 1@ it leans toward+-- hard-thresholding. Typical value @γ ∈ [2, 5]@.+--+-- Coordinate descent update (Breheny-Huang 2011, with column-norm @cSq@):+--+-- @+-- z = ρ_j+-- β_j = S(z, λ) / (cSq - 1/γ) if |z| ≤ γλ·cSq+-- = z / cSq if |z| > γλ·cSq+-- @+--+-- Assumes @cSq > 1/γ@ (i.e. local convexity outweighs the penalty's+-- concavity). With standardized @X@ (cSq ≈ 1), this is automatically+-- satisfied when @γ > 1@. If violated, the inner CD may diverge; callers+-- are recommended to use @standardize@ + @γ ≥ 3@.+fitMCP+ :: Double -- ^ @λ@+ -> Double -- ^ [日本語]: @γ@ (concavity、 推奨 @≥ 3@)。 [English]: @γ@ (concavity; recommended @≥ 3@).+ -> LA.Matrix Double -- ^ X+ -> LA.Vector Double -- ^ y+ -> Int -- ^ max CD iterations+ -> Double -- ^ tolerance+ -> RegFit+fitMCP lambda gamma x y maxIter tol =+ let upd rho cSq =+ let z = rho+ thresh = gamma * lambda * cSq+ in if abs z <= thresh+ then+ let denom = cSq - 1 / gamma+ in if denom <= 0+ then z / cSq -- 非凸時は OLS 解で fallback+ else softThreshold z lambda / denom+ else z / cSq+ (betaFinal, iters) = cdLoop x y maxIter tol upd+ yHat = x LA.#> betaFinal+ r = y - yHat+ in mkRegFit betaFinal yHat r y (L1 lambda) iters++-- ---------------------------------------------------------------------------+-- 31-A3: SCAD (Smoothly Clipped Absolute Deviation、 Fan-Li 2001)+-- ---------------------------------------------------------------------------++-- | [日本語]: SCAD non-convex 罰則 (区分三次):+--+-- @+-- p'_{λ,a}(|β|) = λ if |β| ≤ λ+-- = (aλ - |β|)/(a-1) if λ < |β| ≤ aλ+-- = 0 if |β| > aλ+-- @+--+-- 典型値 @a = 3.7@ (Fan-Li 2001 推奨)。+--+-- Coordinate descent 更新 (Breheny-Huang 2011):+--+-- @+-- z = ρ_j+-- if |z| ≤ λ·(1 + cSq) : β_j = S(z, λ) / cSq -- Lasso 領域+-- elif |z| ≤ a·λ·cSq : β_j = S(z, aλ/(a-1)) / (cSq - 1/(a-1))+-- else : β_j = z / cSq -- OLS 領域+-- @+--+-- [English]: SCAD non-convex penalty (piecewise cubic):+--+-- @+-- p'_{λ,a}(|β|) = λ if |β| ≤ λ+-- = (aλ - |β|)/(a-1) if λ < |β| ≤ aλ+-- = 0 if |β| > aλ+-- @+--+-- Typical value @a = 3.7@ (recommended by Fan-Li 2001).+--+-- Coordinate descent update (Breheny-Huang 2011):+--+-- @+-- z = ρ_j+-- if |z| ≤ λ·(1 + cSq) : β_j = S(z, λ) / cSq -- Lasso region+-- elif |z| ≤ a·λ·cSq : β_j = S(z, aλ/(a-1)) / (cSq - 1/(a-1))+-- else : β_j = z / cSq -- OLS region+-- @+fitSCAD+ :: Double -- ^ @λ@+ -> Double -- ^ [日本語]: @a@ (= 3.7 推奨)。 [English]: @a@ (recommended @= 3.7@).+ -> LA.Matrix Double+ -> LA.Vector Double+ -> Int -> Double+ -> RegFit+fitSCAD lambda a x y maxIter tol =+ let upd rho cSq =+ let z = rho+ absZ = abs z+ in if absZ <= lambda * (1 + cSq)+ then softThreshold z lambda / cSq+ else if absZ <= a * lambda * cSq+ then+ let denom = cSq - 1 / (a - 1)+ thr = a * lambda / (a - 1)+ in if denom <= 0+ then z / cSq+ else softThreshold z thr / denom+ else z / cSq+ (betaFinal, iters) = cdLoop x y maxIter tol upd+ yHat = x LA.#> betaFinal+ r = y - yHat+ in mkRegFit betaFinal yHat r y (L1 lambda) iters++-- ---------------------------------------------------------------------------+-- 31-A4: Group Lasso (Yuan-Lin 2006)+-- ---------------------------------------------------------------------------++-- | [日本語]: Group Lasso: @argmin (1/2n)|y - Xβ|² + λ Σ_g √|g| · |β_g|₂@+-- (group ごと L2 ノルムの和で penalize、 group 全体を 0 / non-0 にする)。+--+-- 解法: block coordinate descent。 各 group @g@ について部分残差+-- @r_g = r + X_g β_g@ を作り、 group 更新+--+-- @+-- z_g = X_gᵀ r_g / n+-- β_g_new = (1 - λ √|g| / |z_g|₂)_+ · z_g / cSq_g+-- @+--+-- ここで @cSq_g = |X_g|² / n@ (group 内列ノルム合計、 簡易には 1 を仮定)、+-- @(·)_+@ は max(·, 0)。 Yuan-Lin 2006 の uncorrelated-within-group 想定で+-- 動く simplified version。+--+-- @groups@ は @[[Int]]@ で、 各内側リストが列 index の集合 (重複・順不同可)。+-- 列 index が複数 group に現れた場合は最初の group のみ扱われる。+--+-- [English]: Group Lasso: @argmin (1/2n)|y - Xβ|² + λ Σ_g √|g| · |β_g|₂@+-- (penalizes the sum of L2 norms per group, driving whole groups to 0 or+-- non-0).+--+-- Solved via block coordinate descent. For each group @g@, a partial+-- residual @r_g = r + X_g β_g@ is formed, and the group is updated as+--+-- @+-- z_g = X_gᵀ r_g / n+-- β_g_new = (1 - λ √|g| / |z_g|₂)_+ · z_g / cSq_g+-- @+--+-- where @cSq_g = |X_g|² / n@ (sum of within-group column norms, assumed+-- @1@ in the simplified case) and @(·)_+@ is max(·, 0). This is the+-- simplified version that works under Yuan-Lin 2006's+-- uncorrelated-within-group assumption.+--+-- @groups@ is @[[Int]]@, where each inner list is a set of column indices+-- (duplicates \/ any order allowed). If a column index appears in multiple+-- groups, only the first group is used.+fitGroupLasso+ :: Double -- ^ @λ@+ -> [[Int]] -- ^ [日本語]: group 分割 (列 index)。 [English]: group partitioning (column indices).+ -> LA.Matrix Double -- ^ X (n × p)+ -> LA.Vector Double -- ^ y+ -> Int -- ^ max iterations+ -> Double -- ^ tolerance+ -> RegFit+fitGroupLasso lambda groups x y maxIter tol =+ let n = LA.rows x+ nD = fromIntegral n :: Double+ p = LA.cols x+ -- group ごとに前計算する design submatrix と column-norm sum+ gPrep = [ (gValid, x LA.¿ gValid, gSize gValid)+ | g <- groups+ , let gValid = [j | j <- g, j >= 0, j < p]+ , not (null gValid) ]+ gSize g = sqrt (fromIntegral (length g)) -- √|g|+ -- 反復: β_g を block 更新+ step beta resid =+ foldl+ (\(bAcc, rAcc) (gIdx, xG, gW) ->+ let -- 部分残差 r_g = r + X_g β_g+ bG = LA.fromList [ LA.atIndex bAcc j | j <- gIdx ]+ rG = rAcc + xG LA.#> bG+ z = LA.tr xG LA.#> rG / LA.scalar nD+ zNorm = LA.norm_2 z+ cSqG = LA.sumElements (xG * xG) / nD+ thr = lambda * gW+ bGnew = if zNorm <= thr || cSqG <= 0+ then LA.konst 0 (LA.size z)+ else LA.scale ((1 - thr / zNorm) / cSqG) z+ -- 残差を新 β_g で更新: r ← r - X_g (β_g_new - β_g)+ rNew = rG - xG LA.#> bGnew+ bAcc' = updateIndices bAcc gIdx (LA.toList bGnew)+ in (bAcc', rNew))+ (beta, resid) gPrep+ loop !k !beta !resid =+ if k >= maxIter+ then (beta, k)+ else+ let (betaNew, residNew) = step beta resid+ diff = LA.norm_2 (betaNew - beta)+ in if diff < tol+ then (betaNew, k + 1)+ else loop (k + 1) betaNew residNew+ beta0 = LA.konst 0 p+ (betaFinal, iters) = loop 0 beta0 y+ yHat = x LA.#> betaFinal+ r = y - yHat+ in mkRegFit betaFinal yHat r y (L1 lambda) iters++-- | [日本語]: Vector の特定 index 群を新値で置き換える (immutable 経由)。 Group Lasso+-- 専用のため module 内部 helper。+-- [English]: Replaces a specific set of vector indices with new values (via+-- an immutable copy). An internal helper dedicated to Group Lasso.+updateIndices :: LA.Vector Double -> [Int] -> [Double] -> LA.Vector Double+updateIndices v idx vals =+ let xs = LA.toList v+ m = zip idx vals+ n = length xs+ lookupNew j = case lookup j m of+ Just nv -> nv+ Nothing -> xs !! j+ in LA.fromList [ lookupNew j | j <- [0 .. n - 1] ]
+ src/Hanalyze/Model/Reliability.hs view
@@ -0,0 +1,332 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE BangPatterns #-}+-- |+-- Module : Hanalyze.Model.Reliability+-- Description : 信頼性解析 — 加速寿命試験モデル群 (Arrhenius / Eyring / Inverse Power Law)+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- [日本語]: 信頼性解析: 加速寿命試験のモデル群。+--+-- ストレス変数 (温度 / 電圧 / 湿度等) と寿命の関係を回帰し、 使用条件下での+-- 寿命予測や加速係数を計算する。+--+-- 提供するモデル:+--+-- - 'fitArrhenius' — 温度ストレス: @t = A · exp(Ea / (k_B · T))@+-- - 'fitEyring' — 温度 + 1 ストレス: 半導体 EM 等+-- - 'fitInversePower' — 電圧 / 機械応力: @t = A · S^(-n)@+--+-- いずれも対数寿命を線形モデルとして fit する (古典的アプローチ)。+-- 寿命分布の指定が必要な場合は 'Hanalyze.Model.Weibull' の MLE 結果を+-- 入力として渡すバリアント (本モジュールの提供外、 別フェーズで検討)。+--+-- [English]: Reliability analysis: a family of accelerated life-testing+-- models.+--+-- Regresses the relationship between stress variables (temperature \/+-- voltage \/ humidity, etc.) and lifetime, computing lifetime predictions+-- under use conditions and acceleration factors.+--+-- Models provided:+--+-- - 'fitArrhenius' — temperature stress: @t = A · exp(Ea / (k_B · T))@+-- - 'fitEyring' — temperature + 1 stress: semiconductor EM etc.+-- - 'fitInversePower' — voltage \/ mechanical stress: @t = A · S^(-n)@+--+-- All of these fit log-lifetime as a linear model (the classical approach).+-- A variant that takes MLE results from 'Hanalyze.Model.Weibull' as+-- input, for cases where the lifetime distribution must be specified, is+-- out of scope for this module (to be considered in a separate phase).+module Hanalyze.Model.Reliability+ ( -- * Arrhenius+ ArrheniusFit (..)+ , fitArrhenius+ , accelerationFactor+ -- * Eyring+ , EyringFit (..)+ , fitEyring+ -- * Inverse Power Law+ , InversePowerFit (..)+ , fitInversePower+ -- * 共通定数+ , kBoltzmann+ ) where++import qualified Numeric.LinearAlgebra as LA+import Data.Text (Text)++-- ===========================================================================+-- 共通定数+-- ===========================================================================++-- | [日本語]: Boltzmann 定数 (eV/K)。 Arrhenius / Eyring で温度ストレスに使う。+-- [English]: The Boltzmann constant (eV\/K). Used for temperature stress+-- in Arrhenius \/ Eyring.+kBoltzmann :: Double+kBoltzmann = 8.617333262145e-5++-- ===========================================================================+-- Arrhenius モデル+-- ===========================================================================++-- | [日本語]: Arrhenius fit: @t = A · exp(Ea / (k_B · T))@+-- [English]: Arrhenius fit: @t = A · exp(Ea / (k_B · T))@.+data ArrheniusFit = ArrheniusFit+ { afA :: !Double -- ^ [日本語]: 前指数因子 A。 [English]: The pre-exponential factor A.+ , afEa :: !Double -- ^ [日本語]: 活性化エネルギー Ea (eV)。 [English]: The activation energy Ea (eV).+ , afLogLik :: !Double -- ^ [日本語]: 対数尤度 (Gaussian residual 仮定)。 [English]: The log-likelihood (assuming Gaussian residuals).+ , afN :: !Int -- ^ [日本語]: 観測 (温度 × 寿命) 数。 [English]: The number of observations (temperature × lifetime).+ } deriving (Show)++-- | [日本語]: Arrhenius モデルの fit。+--+-- 入力: @[(temperature_K, [lifetimes])]@ の対、 温度ごとに複数寿命を観測。+-- 解法: log t = log A + Ea/k_B · (1/T) を OLS で解く (= 線形回帰)。+-- 戻り値: @A@ と @Ea (eV)@ の点推定、 log-likelihood (Gaussian residual 仮定)。+--+-- 失敗条件:+--+-- - 入力が空または全観測 0 個 → Left+-- - 温度水準が 1 種類しかない (= 傾き決定不能) → Left+-- - 任意の温度 ≤ 0 や寿命 ≤ 0 → Left (log 取得不能)+--+-- [English]: Fits the Arrhenius model.+--+-- Input: pairs of @[(temperature_K, [lifetimes])]@, with multiple lifetimes+-- observed per temperature.+-- Method: solves log t = log A + Ea/k_B · (1/T) via OLS (= linear+-- regression).+-- Returns: point estimates of @A@ and @Ea (eV)@, plus the log-likelihood+-- (assuming Gaussian residuals).+--+-- Failure conditions:+--+-- - Input is empty or has zero observations overall → Left+-- - Only one distinct temperature level (= slope cannot be determined) →+-- Left+-- - Any temperature ≤ 0 or lifetime ≤ 0 → Left (log cannot be taken)+fitArrhenius :: [(Double, [Double])] -> Either Text ArrheniusFit+fitArrhenius input = do+ () <- if null input then Left "fitArrhenius: empty input" else Right ()+ let allPairs =+ [ (t, life)+ | (t, lives) <- input+ , life <- lives+ ]+ () <- if null allPairs+ then Left "fitArrhenius: no lifetime observations across all temperatures"+ else Right ()+ () <- if any (\(t, l) -> t <= 0 || l <= 0) allPairs+ then Left "fitArrhenius: temperatures and lifetimes must all be > 0"+ else Right ()+ let distinctTemps = length (nubByDouble (map fst allPairs))+ () <- if distinctTemps < 2+ then Left "fitArrhenius: need at least 2 distinct temperatures"+ else Right ()+ -- (x, y) = (1/T, log t)+ let xs = map (\(t, _) -> 1 / t) allPairs+ ys = map (\(_, l) -> log l) allPairs+ n = length allPairs+ meanX = sum xs / fromIntegral n+ meanY = sum ys / fromIntegral n+ sxx = sum [ (x - meanX) ** 2 | x <- xs ]+ sxy = sum [ (x - meanX) * (y - meanY) | (x, y) <- zip xs ys ]+ () <- if sxx <= 0+ then Left "fitArrhenius: zero variance in 1/T (numerical issue)"+ else Right ()+ let b1 = sxy / sxx -- slope = Ea / k_B+ b0 = meanY - b1 * meanX -- intercept = log A+ a = exp b0+ ea = b1 * kBoltzmann+ yHat = [ b0 + b1 * x | x <- xs ]+ sse = sum [ (y - yh) ** 2 | (y, yh) <- zip ys yHat ]+ sigma2 = if n > 2 then sse / fromIntegral (n - 2) else sse / fromIntegral n+ ll = -0.5 * fromIntegral n * (log (2 * pi * sigma2) + 1)+ Right ArrheniusFit+ { afA = a+ , afEa = ea+ , afLogLik = ll+ , afN = n+ }++-- | [日本語]: 重複除去 (浮動小数点許容なし、 完全一致のみ)。+-- [English]: Deduplication (no floating-point tolerance, exact match+-- only).+nubByDouble :: [Double] -> [Double]+nubByDouble = go []+ where+ go acc [] = reverse acc+ go acc (x:xs) | x `elem` acc = go acc xs+ | otherwise = go (x : acc) xs++-- | [日本語]: 加速係数 AF = exp(Ea/k_B · (1/T_use - 1/T_test))+-- [English]: The acceleration factor AF = exp(Ea/k_B · (1/T_use -+-- 1/T_test)).+accelerationFactor :: ArrheniusFit -> Double -> Double -> Double+accelerationFactor fit tUse tTest =+ exp (afEa fit / kBoltzmann * (1/tUse - 1/tTest))++-- ===========================================================================+-- Eyring モデル (Phase 2.6)+-- ===========================================================================++-- | [日本語]: Eyring fit: @t = A · T^(-1) · exp(Ea / (k_B · T)) · exp(B · S)@+-- (温度 T と 1 ストレス変数 S)+-- [English]: Eyring fit: @t = A · T^(-1) · exp(Ea / (k_B · T)) ·+-- exp(B · S)@ (temperature T and 1 stress variable S).+data EyringFit = EyringFit+ { efA :: !Double+ , efEa :: !Double+ , efB :: !Double -- ストレス係数+ , efLogLik :: !Double+ , efN :: !Int+ } deriving (Show)++-- | [日本語]: Eyring モデルの fit。+--+-- モデル: @t · T = A · exp(Ea / (k_B · T)) · exp(B · S)@+-- 等価に: @log t = log A − log T + Ea/(k_B · T) + B · S@+--+-- 入力: @[(temperature_K, stress, [lifetimes])]@。 各 (T, S) 組合せで複数寿命可。+-- 解法: y = log t + log T を (1/T, S) の 2 変量 OLS で fit (intercept 含む)。+-- β0 = log A、 β1 = Ea / k_B、 β2 = B+--+-- [English]: Fits the Eyring model.+--+-- Model: @t · T = A · exp(Ea / (k_B · T)) · exp(B · S)@+-- Equivalently: @log t = log A − log T + Ea/(k_B · T) + B · S@+--+-- Input: @[(temperature_K, stress, [lifetimes])]@. Multiple lifetimes are+-- allowed per (T, S) combination.+-- Method: fits y = log t + log T via bivariate OLS on (1/T, S) (including+-- an intercept).+-- β0 = log A, β1 = Ea / k_B, β2 = B+fitEyring :: [(Double, Double, [Double])] -> Either Text EyringFit+fitEyring input = do+ () <- if null input then Left "fitEyring: empty input" else Right ()+ let pairs =+ [ (t, s, life)+ | (t, s, lives) <- input+ , life <- lives+ ]+ () <- if null pairs+ then Left "fitEyring: no lifetime observations"+ else Right ()+ () <- if any (\(t, _, l) -> t <= 0 || l <= 0) pairs+ then Left "fitEyring: temperatures and lifetimes must be > 0"+ else Right ()+ let distinctTS = nubByPair [ (t, s) | (t, s, _) <- pairs ]+ () <- if length distinctTS < 3+ then Left "fitEyring: need at least 3 distinct (T, S) combinations"+ else Right ()+ let xRows = [ [1, 1 / t, s] | (t, s, _) <- pairs ]+ ys = [ log l + log t | (t, _, l) <- pairs ]+ xMat = LA.fromLists xRows :: LA.Matrix Double+ yVec = LA.fromList ys :: LA.Vector Double+ -- normal equations: β = (XᵀX)⁻¹ Xᵀy+ xt = LA.tr xMat+ xtx = xt LA.<> xMat+ xty = xt LA.#> yVec+ betaList <- case LA.linearSolve xtx (LA.asColumn xty) of+ Just m -> Right (LA.toList (LA.flatten m))+ Nothing -> Left "fitEyring: design matrix is singular (collinear T/S?)"+ case betaList of+ [b0, b1, b2] -> do+ let n = length pairs+ a = exp b0+ ea = b1 * kBoltzmann+ bCoef = b2+ yHat = LA.toList (xMat LA.#> LA.fromList [b0, b1, b2])+ sse = sum [ (y - yh) ** 2 | (y, yh) <- zip ys yHat ]+ dof = max 1 (n - 3)+ sigma2 = sse / fromIntegral dof+ ll = -0.5 * fromIntegral n * (log (2 * pi * sigma2) + 1)+ Right EyringFit+ { efA = a+ , efEa = ea+ , efB = bCoef+ , efLogLik = ll+ , efN = n+ }+ _ -> Left "fitEyring: linearSolve returned unexpected length"++-- | [日本語]: (T, S) ペアの重複除去。+-- [English]: Deduplicates (T, S) pairs.+nubByPair :: [(Double, Double)] -> [(Double, Double)]+nubByPair = go []+ where+ go acc [] = reverse acc+ go acc (p:ps) | p `elem` acc = go acc ps+ | otherwise = go (p : acc) ps++-- ===========================================================================+-- Inverse Power Law モデル (Phase 2.6)+-- ===========================================================================++-- | [日本語]: Inverse Power Law fit: @t = A · S^(-n)@+-- [English]: Inverse Power Law fit: @t = A · S^(-n)@.+data InversePowerFit = InversePowerFit+ { ipfA :: !Double+ , ipfN :: !Double -- パワー指数+ , ipfLogLik :: !Double+ , ipfNobs :: !Int+ } deriving (Show)++-- | [日本語]: Inverse Power Law モデルの fit。+--+-- モデル: @t = A · S^(-n)@+-- log 変換: @log t = log A − n · log S@+--+-- 入力: @[(stress, [lifetimes])]@。 stress > 0、 lifetime > 0 必須。+-- 解法: y = log t を log S の単変量 OLS で fit。 傾き = -n。+--+-- [English]: Fits the Inverse Power Law model.+--+-- Model: @t = A · S^(-n)@+-- Log transform: @log t = log A − n · log S@+--+-- Input: @[(stress, [lifetimes])]@. Requires stress > 0 and lifetime > 0.+-- Method: fits y = log t via univariate OLS on log S. Slope = -n.+fitInversePower :: [(Double, [Double])] -> Either Text InversePowerFit+fitInversePower input = do+ () <- if null input then Left "fitInversePower: empty input" else Right ()+ let pairs =+ [ (s, life)+ | (s, lives) <- input+ , life <- lives+ ]+ () <- if null pairs+ then Left "fitInversePower: no lifetime observations"+ else Right ()+ () <- if any (\(s, l) -> s <= 0 || l <= 0) pairs+ then Left "fitInversePower: stress and lifetimes must be > 0"+ else Right ()+ let distinctS = length (nubByDouble (map fst pairs))+ () <- if distinctS < 2+ then Left "fitInversePower: need at least 2 distinct stress levels"+ else Right ()+ let xs = map (\(s, _) -> log s) pairs+ ys = map (\(_, l) -> log l) pairs+ n = length pairs+ meanX = sum xs / fromIntegral n+ meanY = sum ys / fromIntegral n+ sxx = sum [ (x - meanX) ** 2 | x <- xs ]+ sxy = sum [ (x - meanX) * (y - meanY) | (x, y) <- zip xs ys ]+ () <- if sxx <= 0+ then Left "fitInversePower: zero variance in log S"+ else Right ()+ let slope = sxy / sxx -- = -n+ b0 = meanY - slope * meanX+ a = exp b0+ nExp = - slope+ yHat = [ b0 + slope * x | x <- xs ]+ sse = sum [ (y - yh) ** 2 | (y, yh) <- zip ys yHat ]+ sigma2 = if n > 2 then sse / fromIntegral (n - 2) else sse / fromIntegral n+ ll = -0.5 * fromIntegral n * (log (2 * pi * sigma2) + 1)+ Right InversePowerFit+ { ipfA = a+ , ipfN = nExp+ , ipfLogLik = ll+ , ipfNobs = n+ }
+ src/Hanalyze/Model/ReliabilityBlockDiagram.hs view
@@ -0,0 +1,88 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE BangPatterns #-}++-- |+-- Module : Hanalyze.Model.ReliabilityBlockDiagram+-- Description : 信頼性ブロック図 (RBD) の直列/並列/k-out-of-n 再帰合成による系全体信頼度計算+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- Reliability Block Diagram (RBD).+--+-- Computes system reliability from a structural composition of components+-- with known individual reliabilities. The three primitive combinators+-- are the textbook ones (e.g. O'Connor & Kleyner, /Practical Reliability+-- Engineering/):+--+-- * Series (every block must work):+-- @R = ∏ Rᵢ@+-- * Parallel (any block working suffices):+-- @R = 1 − ∏ (1 − Rᵢ)@+-- * k-out-of-n (at least @k@ of @n@ blocks must work):+-- @R = Σ_{i = k}^{n} P(exactly i succeed)@+-- computed by Poisson-binomial DP — works with heterogeneous block+-- reliabilities (the binomial closed form is the homogeneous+-- special case).+--+-- Blocks can be arbitrarily nested. Failure independence between blocks+-- is assumed (the standard RBD assumption).+--+-- @+-- import Hanalyze.Model.ReliabilityBlockDiagram+--+-- -- Two-out-of-three redundancy of three series strings:+-- let sys = KofN 2 [ Series [Leaf 0.95, Leaf 0.99]+-- , Series [Leaf 0.95, Leaf 0.99]+-- , Series [Leaf 0.95, Leaf 0.99] ]+-- r = reliabilityOf sys+-- @+--+-- == Implemented+--+-- * 'RBDBlock' — composable tree of components.+-- * 'reliabilityOf' — recursive evaluation.+module Hanalyze.Model.ReliabilityBlockDiagram+ ( RBDBlock (..)+ , reliabilityOf+ ) where++-- ---------------------------------------------------------------------------+-- Types+-- ---------------------------------------------------------------------------++-- | A block in a reliability diagram. @Leaf p@ is a single component with+-- reliability @p ∈ [0, 1]@; the other constructors compose sub-blocks.+data RBDBlock+ = Leaf !Double+ | Series ![RBDBlock]+ | Parallel ![RBDBlock]+ | KofN !Int ![RBDBlock]+ deriving (Show, Eq)++-- ---------------------------------------------------------------------------+-- Evaluation+-- ---------------------------------------------------------------------------++-- | System reliability of a block, in @[0, 1]@. Component reliabilities+-- are assumed independent (the standard RBD assumption).+reliabilityOf :: RBDBlock -> Double+reliabilityOf (Leaf p) = p+reliabilityOf (Series bs) = product (map reliabilityOf bs)+reliabilityOf (Parallel bs) = 1 - product [ 1 - reliabilityOf b | b <- bs ]+reliabilityOf (KofN k bs)+ | k <= 0 = 1 -- always satisfied+ | k > length bs = 0 -- impossible+ | otherwise =+ let ps = map reliabilityOf bs+ n = length ps+ -- Poisson-binomial DP: pmf!!i = P(exactly i blocks work).+ pmf = foldr step [1.0] ps+ where+ step pi acc =+ -- acc = pmf of current partial product (length = current j + 1).+ let len = length acc+ in [ let aPrev = if i - 1 >= 0 then acc !! (i - 1) else 0+ aHere = if i < len then acc !! i else 0+ in pi * aPrev + (1 - pi) * aHere+ | i <- [0 .. len] ]+ in sum (drop k pmf)
+ src/Hanalyze/Model/Robust.hs view
@@ -0,0 +1,304 @@+-- |+-- Module : Hanalyze.Model.Robust+-- Description : IRLS による Huber / Tukey biweight ロバスト回帰 (M-estimator)+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- [日本語]: IRLS による Huber / Tukey biweight ロバスト回帰 (M-estimator)。+--+-- 外れ値を含むデータに対する線形回帰。 OLS の二乗損失を bounded influence+-- 関数 (Huber / Tukey biweight) に置き換え、 Iteratively Reweighted Least+-- Squares で β を求める。 JMP "Fit Model > Personality: Robust Fit"、+-- R `MASS::rlm` 相当。+--+-- ## アルゴリズム+--+-- 1. β を OLS で初期化+-- 2. 残差 @r_i = y_i - x_i^T β@ を計算+-- 3. ロバストスケール推定 @σ̂ = MAD(r) / 0.6745@+-- 4. 影響関数から重み @w_i@ を計算 ('huberWeight' / 'tukeyWeight')+-- 5. 加重 LS で β を更新: @β ← (X^T W X)^{-1} X^T W y@+-- 6. 収束まで 2-5 を繰り返す+--+-- ## 推定子の選択+--+-- - __Huber__ (@k=1.345@、 95% 効率): 線形 + 線形クリップ、 滑らか、 標準+-- - __Tukey biweight__ (@c=4.685@、 95% 効率): 完全棄却閾値付き、 外れ値の+-- 影響を 0 に落とす、 だが多峰目的関数 (OLS 初期化が重要)+--+-- Reference:+-- Huber (1964) "Robust estimation of a location parameter".+-- Tukey (1977) biweight、 Rousseeuw-Leroy (1987) 教科書。+--+-- [English]: Huber \/ Tukey biweight robust regression (M-estimator) via IRLS.+--+-- Linear regression for data containing outliers. Replaces OLS's squared+-- loss with a bounded-influence function (Huber \/ Tukey biweight) and+-- solves for β with Iteratively Reweighted Least Squares. Equivalent to+-- JMP's "Fit Model > Personality: Robust Fit" or R's @MASS::rlm@.+--+-- ## Algorithm+--+-- 1. Initialize β with OLS+-- 2. Compute residuals @r_i = y_i - x_i^T β@+-- 3. Estimate robust scale @σ̂ = MAD(r) / 0.6745@+-- 4. Compute weights @w_i@ from the influence function ('huberWeight' \/ 'tukeyWeight')+-- 5. Update β via weighted LS: @β ← (X^T W X)^{-1} X^T W y@+-- 6. Repeat 2-5 until convergence+--+-- ## Choice of estimator+--+-- - __Huber__ (@k=1.345@, 95% efficiency): linear + linear clipping, smooth, standard+-- - __Tukey biweight__ (@c=4.685@, 95% efficiency): has a hard rejection+-- threshold, drives the influence of outliers to 0, but has a multimodal+-- objective function (OLS initialization matters)+--+-- Reference:+-- Huber (1964) "Robust estimation of a location parameter".+-- Tukey (1977) biweight, Rousseeuw-Leroy (1987) textbook.+module Hanalyze.Model.Robust+ ( RobustEstimator (..)+ , RobustFit (..)+ , defaultHuberK+ , defaultTukeyC+ , fitRobustLM+ , huberWeight+ , tukeyWeight+ , psiFn+ , psiDerivFn+ , robustCovBeta+ ) where++import qualified Numeric.LinearAlgebra as LA+import Data.List (sort)++-- ---------------------------------------------------------------------------+-- 型+-- ---------------------------------------------------------------------------++-- | [日本語]: M-estimator の選択。 LTS (Least Trimmed Squares) は非凸組合せ+-- 最適化なので、 別途候補として今後の対応課題とする (regression-advanced 系+-- の後続検討 §RR3 参照)。+-- [English]: Choice of M-estimator. LTS (Least Trimmed Squares) is a+-- non-convex combinatorial optimization problem, so it remains a future+-- candidate for later work (see the follow-up discussion in the+-- regression-advanced material, §RR3).+data RobustEstimator+ = Huber !Double -- ^ [日本語]: @k@ (= 1.345 で 95% 効率、 = 'defaultHuberK')。 [English]: @k@ (= 1.345 for 95% efficiency, = 'defaultHuberK').+ | Tukey !Double -- ^ [日本語]: @c@ (= 4.685 で 95% 効率、 = 'defaultTukeyC')。 [English]: @c@ (= 4.685 for 95% efficiency, = 'defaultTukeyC').+ deriving (Show, Eq)++data RobustFit = RobustFit+ { rfCoef :: !(LA.Vector Double) -- ^ [日本語]: 係数 β̂。 [English]: Coefficients β̂.+ , rfScale :: !Double -- ^ [日本語]: ロバストスケール σ̂ (MAD-based)。 [English]: Robust scale σ̂ (MAD-based).+ , rfWeights :: !(LA.Vector Double) -- ^ [日本語]: 最終 IRLS 重み (≤ 1)。 [English]: Final IRLS weights (≤ 1).+ , rfFitted :: !(LA.Vector Double) -- ^ [日本語]: ŷ = Xβ̂。 [English]: ŷ = Xβ̂.+ , rfResiduals :: !(LA.Vector Double) -- ^ [日本語]: y - ŷ。 [English]: y - ŷ.+ , rfIterations :: !Int -- ^ [日本語]: IRLS 反復回数。 [English]: Number of IRLS iterations.+ , rfConverged :: !Bool -- ^ [日本語]: tol 内収束したか。 [English]: Whether it converged within tol.+ , rfEstimator :: !RobustEstimator -- ^ [日本語]: 使用した estimator。 [English]: The estimator used.+ } deriving (Show)++-- | [日本語]: Huber の標準値 (95% Gaussian 効率): @k = 1.345@。+-- [English]: Huber's standard value (95% Gaussian efficiency): @k = 1.345@.+defaultHuberK :: Double+defaultHuberK = 1.345++-- | [日本語]: Tukey biweight の標準値 (95% Gaussian 効率): @c = 4.685@。+-- [English]: Tukey biweight's standard value (95% Gaussian efficiency): @c = 4.685@.+defaultTukeyC :: Double+defaultTukeyC = 4.685++-- ---------------------------------------------------------------------------+-- 重み関数 (= ψ(u)/u where ψ is the influence function)+-- ---------------------------------------------------------------------------++-- | [日本語]: Huber 重み: @w(u) = 1@ if @|u| ≤ k@、 @k/|u|@ otherwise。+-- ここで @u = r / σ@ (標準化残差)。+-- [English]: Huber weight: @w(u) = 1@ if @|u| ≤ k@, @k/|u|@ otherwise.+-- Here @u = r / σ@ (the standardized residual).+huberWeight :: Double -> Double -> Double+huberWeight k u+ | absU <= k = 1+ | absU == 0 = 1+ | otherwise = k / absU+ where absU = abs u++-- | [日本語]: Tukey biweight 重み: @w(u) = (1 - (u/c)²)²@ if @|u| ≤ c@、 @0@ otherwise。+-- [English]: Tukey biweight weight: @w(u) = (1 - (u/c)²)²@ if @|u| ≤ c@, @0@ otherwise.+tukeyWeight :: Double -> Double -> Double+tukeyWeight c u+ | absU >= c = 0+ | otherwise = let t = u / c+ s = 1 - t * t+ in s * s+ where absU = abs u++-- ---------------------------------------------------------------------------+-- 影響関数 ψ とその導関数 ψ' (M 推定量の漸近共分散に使う)+-- ψ(u) = w(u)·u (重み × 標準化残差)。+-- ---------------------------------------------------------------------------++-- | [日本語]: 影響関数 @ψ(u) = w(u)·u@ (= 標準化残差に重みを掛けたスコア)。+-- Huber: @u@ (|u|≤k) / @k·sign u@ (それ以外)。 Tukey: @u(1-(u/c)²)²@ (|u|≤c) / 0。+-- [English]: Influence function @ψ(u) = w(u)·u@ (= the score obtained by+-- weighting the standardized residual). Huber: @u@ (|u|≤k) / @k·sign u@+-- (otherwise). Tukey: @u(1-(u/c)²)²@ (|u|≤c) / 0.+psiFn :: RobustEstimator -> Double -> Double+psiFn (Huber k) u = huberWeight k u * u+psiFn (Tukey c) u = tukeyWeight c u * u++-- | [日本語]: ψ の導関数 @ψ'(u)@ (M 推定量サンドイッチ分散の分母項)。+-- Huber: @1@ (|u|≤k) / @0@。 Tukey: @(1-(u/c)²)(1-5(u/c)²)@ (|u|≤c) / 0。+-- [English]: Derivative of ψ, @ψ'(u)@ (the denominator term of the+-- M-estimator's sandwich variance). Huber: @1@ (|u|≤k) / @0@. Tukey:+-- @(1-(u/c)²)(1-5(u/c)²)@ (|u|≤c) / 0.+psiDerivFn :: RobustEstimator -> Double -> Double+psiDerivFn (Huber k) u = if abs u <= k then 1 else 0+psiDerivFn (Tukey c) u+ | abs u >= c = 0+ | otherwise = let t2 = (u / c) * (u / c)+ in (1 - t2) * (1 - 5 * t2)++-- ---------------------------------------------------------------------------+-- M 推定量の漸近共分散 (サンドイッチ・statsmodels RLM cov="H1")+-- ---------------------------------------------------------------------------++-- | [日本語]: M 推定量 β̂ の漸近共分散行列。 statsmodels @RLM@ 既定 (cov="H1") に一致:+--+-- @+-- u_i = r_i / σ̂ (標準化残差)+-- m = mean ψ'(u_i)+-- K = 1 + (p\/n)·Var(ψ')\/m² (自由度補正)+-- cov = K²·(σ̂²·Σψ(u_i)²\/(n−p))\/m² · (XᵀX)⁻¹+-- @+--+-- SE は @sqrt (diag cov)@、 β̂±z·SE が Wald 信頼区間 (RLM は正規分布で z)。+-- [English]: The asymptotic covariance matrix of the M-estimator β̂.+-- Matches statsmodels @RLM@'s default (cov="H1"):+--+-- @+-- u_i = r_i / σ̂ (standardized residual)+-- m = mean ψ'(u_i)+-- K = 1 + (p\/n)·Var(ψ')\/m² (degrees-of-freedom correction)+-- cov = K²·(σ̂²·Σψ(u_i)²\/(n−p))\/m² · (XᵀX)⁻¹+-- @+--+-- The SE is @sqrt (diag cov)@; β̂±z·SE gives the Wald confidence interval+-- (RLM uses z from the normal distribution).+robustCovBeta+ :: RobustEstimator -- ^ [日本語]: 使用した estimator (ψ/ψ' を決める)。 [English]: The estimator used (determines ψ\/ψ').+ -> Double -- ^ [日本語]: ロバストスケール σ̂ ('rfScale')。 [English]: The robust scale σ̂ ('rfScale').+ -> LA.Vector Double -- ^ [日本語]: 残差 r = y − ŷ ('rfResiduals')。 [English]: Residuals r = y − ŷ ('rfResiduals').+ -> LA.Matrix Double -- ^ [日本語]: 設計行列 X (intercept 列付き)。 [English]: The design matrix X (with an intercept column).+ -> LA.Matrix Double -- ^ [日本語]: β̂ の共分散 (p × p)。 [English]: The covariance of β̂ (p × p).+robustCovBeta est scale resid x =+ let n = LA.rows x+ p = LA.cols x+ u = LA.cmap (/ scale) resid+ pderiv = LA.cmap (psiDerivFn est) u+ m = meanV pderiv+ varpp = meanV (LA.cmap (\v -> (v - m) * (v - m)) pderiv) -- 母分散 (ddof=0)+ kcorr = 1 + (fromIntegral p / fromIntegral n) * varpp / (m * m)+ sspsi = LA.sumElements (LA.cmap (\v -> let pv = psiFn est v in pv * pv) u)+ xtxInv = LA.inv (LA.tr x LA.<> x)+ factor = kcorr * kcorr+ * (sspsi * scale * scale / fromIntegral (n - p)) / (m * m)+ in LA.scale factor xtxInv+ where+ meanV v = LA.sumElements v / fromIntegral (LA.size v)++-- ---------------------------------------------------------------------------+-- IRLS+-- ---------------------------------------------------------------------------++-- | [日本語]: M-estimator IRLS で線形回帰を fit。+--+-- @X@ は @n × p@ (intercept 列は呼び出し側で付加)、 @y@ は長さ @n@。+-- @maxIter@ デフォルト 50、 @tol@ デフォルト 1e-6。+-- [English]: Fits a linear regression via M-estimator IRLS.+--+-- @X@ is @n × p@ (the caller appends the intercept column); @y@ has length+-- @n@. @maxIter@ defaults to 50, @tol@ defaults to 1e-6.+fitRobustLM+ :: RobustEstimator+ -> LA.Matrix Double -- ^ [日本語]: X。 [English]: X.+ -> LA.Vector Double -- ^ [日本語]: y。 [English]: y.+ -> Int -- ^ [日本語]: max IRLS iterations。 [English]: Maximum number of IRLS iterations.+ -> Double -- ^ [日本語]: @|Δβ|₂@ に対する許容誤差。 [English]: Tolerance on @|Δβ|₂@.+ -> RobustFit+fitRobustLM est x y maxIter tol =+ let -- 初期 β: OLS+ beta0 = LA.flatten (x LA.<\> LA.asColumn y)+ step beta =+ let yHat = x LA.#> beta+ resid = y - yHat+ sigma = madScale resid+ sigma' = if sigma < 1e-12 then 1e-12 else sigma+ uVec = LA.cmap (/ sigma') resid+ wVec = case est of+ Huber k -> LA.cmap (huberWeight k) uVec+ Tukey c -> LA.cmap (tukeyWeight c) uVec+ -- 加重 LS: β ← (X^T W X)^{-1} X^T W y+ wDiag = wVec+ xtWx = LA.tr x LA.<> (x * LA.asColumn wDiag)+ xtWy = LA.tr x LA.#> (wDiag * y)+ betaN = LA.flatten (xtWx LA.<\> LA.asColumn xtWy)+ in (betaN, sigma', wVec)+ loop !k !beta+ | k >= maxIter = (beta, k, False)+ | otherwise =+ let (betaN, _, _) = step beta+ diff = LA.norm_2 (betaN - beta)+ in if diff < tol+ then (betaN, k + 1, True)+ else loop (k + 1) betaN+ (betaFinal, iters, converged) = loop 0 beta0+ yHatF = x LA.#> betaFinal+ residF = y - yHatF+ sigmaF = max 1e-12 (madScale residF)+ uF = LA.cmap (/ sigmaF) residF+ wF = case est of+ Huber k -> LA.cmap (huberWeight k) uF+ Tukey c -> LA.cmap (tukeyWeight c) uF+ in RobustFit+ { rfCoef = betaFinal+ , rfScale = sigmaF+ , rfWeights = wF+ , rfFitted = yHatF+ , rfResiduals = residF+ , rfIterations = iters+ , rfConverged = converged+ , rfEstimator = est+ }++-- ---------------------------------------------------------------------------+-- ロバストスケール (Median Absolute Deviation)+-- ---------------------------------------------------------------------------++-- | [日本語]: MAD ベースのロバストスケール推定:+-- @σ̂ = median(|r_i - median(r)|) / 0.6745@ (Gaussian 整合性)。+-- ロバストスケール σ̂ = median(|r|) / Φ⁻¹(0.75)。 残差 r は intercept で中心化済+-- ゆえ __中心 0__ で MAD を取る (= statsmodels RLM の @mad(resid, center=0)@ と一致。+-- median 中心化は二重中心化になり scale が過小になる)。 定数は Φ⁻¹(0.75)=0.674489…。+-- [English]: MAD-based robust scale estimate:+-- @σ̂ = median(|r_i - median(r)|) / 0.6745@ (for Gaussian consistency).+-- The robust scale σ̂ = median(|r|) / Φ⁻¹(0.75). Since the residuals r are+-- already centered by the intercept, the MAD is taken __around 0__ (matching+-- statsmodels RLM's @mad(resid, center=0)@; centering on the median again+-- would double-center and understate the scale). The constant is+-- Φ⁻¹(0.75)=0.674489….+madScale :: LA.Vector Double -> Double+madScale v =+ let dev = map abs (LA.toList v) -- 中心 0 (statsmodels RLM 準拠)+ mad = medianList dev+ in mad / 0.6744897501960817++medianList :: [Double] -> Double+medianList [] = 0+medianList xs =+ let s = sort xs+ n = length s+ in if odd n+ then s !! (n `div` 2)+ else 0.5 * (s !! (n `div` 2 - 1) + s !! (n `div` 2))
+ src/Hanalyze/Model/SVM.hs view
@@ -0,0 +1,359 @@+{-# LANGUAGE BangPatterns #-}++-- |+-- Module : Hanalyze.Model.SVM+-- Description : SMO ソルバによる双対形カーネル SVM (C-SVC)+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- [日本語]: カーネル SVM (双対形・SMO ソルバ)。+--+-- 双対 C-SVC (hinge 損失) を SMO (Platt 1998) で解き、 共有カーネル語彙+-- ('Hanalyze.Model.Kernel': Linear/Poly/RBF/Matern52/Periodic) と+-- __スパースな真のサポートベクタ__ (α>0 の点) を提供する。 既定カーネルは Linear で、+-- 線形 SVM が必要なら kernel=Linear・非線形は RBF/Poly を選ぶ (R `e1071::svm` の kernel= 流)。+--+-- カーネルハイパラは 'KernelParams' (ℓ/σ_f²/period) を持つ。 GP の観測ノイズ σ_n² は+-- SVM には不要なので @GPParams@ でなく 'KernelParams' のみに依存する。+--+-- 双対問題: max_α Σα_i − ½ ΣΣ α_i α_j y_i y_j K(x_i,x_j)+-- s.t. 0 ≤ α_i ≤ C, Σ α_i y_i = 0+--+-- SMO は 2 変数 (α_i, α_j) ずつ解析更新する。 第 1 変数 = KKT 違反点、+-- 第 2 変数 = @|E_i − E_j|@ 最大 (Platt の 2nd heuristic)。+-- __乱数不使用ゆえ純粋・決定的__ (簡易 SMO の+-- ランダム j 選択は使わない)。 予測は Σ_{SV} α_i y_i K(x_i, x) + b (SV のみで決まる)。+--+-- カーネル評価は 'kEvalMV'(距離カーネルは ‖a−b‖²、 内積カーネル Linear/Poly は a·b、+-- Poly の γ は 'kpLengthScale' から γ=1/(2ℓ²)・Linear の倍率は σ_f²)で共有する。+--+-- [English]: Kernel SVM (dual form, SMO solver).+--+-- Solves the dual C-SVC (hinge loss) with SMO (Platt 1998), providing the+-- shared kernel vocabulary ('Hanalyze.Model.Kernel':+-- Linear\/Poly\/RBF\/Matern52\/Periodic) and+-- __truly sparse support vectors__ (points with α>0). The default+-- kernel is Linear; pick kernel=Linear for a linear SVM or RBF\/Poly for a nonlinear one+-- (following R's `e1071::svm` kernel= convention).+--+-- Kernel hyperparameters live in 'KernelParams' (ℓ\/σ_f²\/period). Since+-- SVM has no need for the GP observation noise σ_n², it depends only on+-- 'KernelParams' rather than @GPParams@.+--+-- Dual problem: max_α Σα_i − ½ ΣΣ α_i α_j y_i y_j K(x_i,x_j)+-- s.t. 0 ≤ α_i ≤ C, Σ α_i y_i = 0+--+-- SMO analytically updates two variables (α_i, α_j) at a time. The first+-- variable is the KKT-violating point; the second is chosen to maximize+-- @|E_i − E_j|@ (Platt's 2nd heuristic).+-- __Uses no randomness, hence pure and deterministic__ (unlike the+-- simplified SMO's random j selection).+-- Prediction is Σ_{SV} α_i y_i K(x_i, x) + b (determined only by the SVs).+--+-- Kernel evaluation is shared via 'kEvalMV' (distance kernels use+-- ‖a−b‖²; inner-product kernels Linear\/Poly use a·b; Poly's γ is derived+-- from 'kpLengthScale' as γ=1/(2ℓ²); Linear's scale is σ_f²).+module Hanalyze.Model.SVM+ ( SVMConfig (..)+ , defaultSVM+ , SVM (..)+ , SVMMulti (..)+ , fitSVM+ , fitSVMMulti+ , predictSVMScore+ , predictSVM+ , predictSVMMulti+ , numSupportVectors+ -- * 自動最適化 (k-fold CV グリッド探索・config に畳む)+ , SVMHyper (..)+ , SVMTuneGrid (..)+ , defaultSVMTuneGrid+ , tuneSVM+ ) where++import qualified Data.Vector as V+import qualified Data.Vector.Unboxed as VU+import qualified Numeric.LinearAlgebra as LA+import Data.Text (Text)+import Data.List (nub, sort, maximumBy)+import Data.Ord (comparing)+import Control.Monad.ST (runST)+import qualified System.Random.MWC as MWC+import Hanalyze.Stat.CV (Fold, kFold)+import Hanalyze.Model.Kernel (Kernel (..), KernelParams (..), defaultKernelParams, kEvalMV)++-- ===========================================================================+-- カーネル (共有 'Kernel' + 'KernelParams' を使う)+-- ===========================================================================++-- | [日本語]: Gram 行列 K (n×n)。 K_ij = kEvalMV ker params (row i) (row j)。+-- [English]: The Gram matrix K (n×n). K_ij = kEvalMV ker params (row i) (row j).+kGram :: Kernel -> KernelParams -> LA.Matrix Double -> LA.Matrix Double+kGram ker p x =+ let rv = V.fromList (LA.toRows x) -- boxed Vector of 行ベクトル (O(1) 添字)+ n = V.length rv+ in LA.build (n, n) (\i j -> kEvalMV ker p (rv V.! round i) (rv V.! round j))+ -- NB: LA.build の i,j は Double。 round で Int 添字に戻す (整数値ゆえ安全)。++-- ===========================================================================+-- 設定 / モデル+-- ===========================================================================++data SVMConfig = SVMConfig+ { svmC :: !Double -- ^ [日本語]: 正則化 C (0 ≤ α ≤ C)。 [English]: Regularization C (0 ≤ α ≤ C).+ , svmKernel :: !Kernel -- ^ [日本語]: 共有カーネル (既定 'Linear')。 [English]: The shared kernel (default 'Linear').+ , svmParams :: !KernelParams -- ^ [日本語]: カーネルハイパラ (ℓ→γ=1/2ℓ²、 σ_f²=Linear 倍率)。 [English]: Kernel hyperparameters (ℓ→γ=1/2ℓ²; σ_f²=Linear scale).+ , svmTol :: !Double -- ^ [日本語]: KKT 許容 (E の許容)。 [English]: KKT tolerance (tolerance on E).+ , svmMaxPasses :: !Int -- ^ [日本語]: 変化が無いパスの連続上限 (収束判定)。 [English]: Maximum consecutive no-change passes (convergence check).+ , svmMaxIter :: !Int -- ^ [日本語]: 総パス数の上限 (安全弁)。 [English]: Upper bound on the total number of passes (safety valve).+ , svmHyper :: !SVMHyper -- ^ [日本語]: ハイパラの決め方 (固定 or CV グリッド探索)。 GP の+ -- @HyperStrategy@ と同型: 調整は config に畳み動詞は @svmCls@ 一本。+ -- [English]: How hyperparameters are decided (fixed, or CV grid+ -- search). Mirrors GP's @HyperStrategy@: tuning is folded into+ -- the config, and there is a single verb @svmCls@.+ } deriving (Show)++defaultSVM :: SVMConfig+defaultSVM = SVMConfig+ { svmC = 1.0, svmKernel = Linear, svmParams = defaultKernelParams+ , svmTol = 1e-3, svmMaxPasses = 5, svmMaxIter = 1000+ , svmHyper = SVMFixed }++-- | [日本語]: 学習済カーネル SVM。 __α>0 のサポートベクタのみ__保持 (スパース)。+-- [English]: A fitted kernel SVM. Retains+-- __only the support vectors with α>0__ (sparse).+data SVM = SVM+ { svmSVx :: !(LA.Matrix Double) -- ^ [日本語]: サポートベクタ (n_sv × d)。 [English]: Support vectors (n_sv × d).+ , svmSVy :: !(VU.Vector Double) -- ^ [日本語]: その符号ラベル ±1。 [English]: Their sign labels ±1.+ , svmSVa :: !(VU.Vector Double) -- ^ [日本語]: 双対係数 α (>0)。 [English]: Dual coefficients α (>0).+ , svmB :: !Double -- ^ [日本語]: バイアス。 [English]: Bias.+ , svmKern :: !Kernel -- ^ [日本語]: 共有カーネル。 [English]: The shared kernel.+ , svmKParams :: !KernelParams -- ^ [日本語]: カーネルハイパラ (予測時に再利用)。 [English]: Kernel hyperparameters (reused at prediction time).+ } deriving (Show)++-- | [日本語]: サポートベクタ数 (= α>0 の点数)。+-- [English]: Number of support vectors (= the count of points with α>0).+numSupportVectors :: SVM -> Int+numSupportVectors = LA.rows . svmSVx++-- ===========================================================================+-- SMO (双対・2 クラス {0,1} → ±1)+-- ===========================================================================++-- | [日本語]: 2 クラス C-SVC を SMO で学習 (y ∈ {0,1})。 決定的 (乱数不使用)。+-- [English]: Fit a binary C-SVC via SMO (y ∈ {0,1}). Deterministic (no randomness).+fitSVM :: SVMConfig -> LA.Matrix Double -> VU.Vector Int -> SVM+fitSVM cfg x yInt =+ let !n = LA.rows x+ ys = VU.generate n (\i -> if yInt VU.! i == 0 then -1 else 1) :: VU.Vector Double+ gram = kGram (svmKernel cfg) (svmParams cfg) x+ cC = svmC cfg+ tol = svmTol cfg+ kij i j = gram `LA.atIndex` (i, j)+ -- 決定関数 f(i) = Σ_j α_j y_j K_ij + b+ decision al b i = b + sum [ al VU.! j * ys VU.! j * kij i j | j <- [0 .. n - 1] ]+ -- 1 パス: 全 i を走査し KKT 違反点を見つけ第 2 変数を選んで更新。+ onePass (!al0, !b0) =+ let step (al, b, changed) i =+ let ei = decision al b i - ys VU.! i+ ai = al VU.! i; yi = ys VU.! i+ viol = (yi * ei < negate tol && ai < cC) || (yi * ei > tol && ai > 0)+ in if not viol then (al, b, changed)+ else+ -- 第 2 変数 j = |E_i − E_j| 最大 (j /= i)。+ let es = [ (j, decision al b j - ys VU.! j) | j <- [0 .. n - 1], j /= i ]+ (j, ej) = maximumBy (comparing (\(_, e) -> abs (ei - e))) es+ aj = al VU.! j; yj = ys VU.! j+ (lo, hi) = if yi /= yj+ then (max 0 (aj - ai), min cC (cC + aj - ai))+ else (max 0 (ai + aj - cC), min cC (ai + aj))+ eta = 2 * kij i j - kij i i - kij j j+ in if lo >= hi || eta >= 0 then (al, b, changed)+ else+ let ajNew0 = aj - yj * (ei - ej) / eta+ ajNew = min hi (max lo ajNew0)+ in if abs (ajNew - aj) < 1e-5 then (al, b, changed)+ else+ let aiNew = ai + yi * yj * (aj - ajNew)+ al' = al VU.// [(i, aiNew), (j, ajNew)]+ b1 = b - ei - yi * (aiNew - ai) * kij i i+ - yj * (ajNew - aj) * kij i j+ b2 = b - ej - yi * (aiNew - ai) * kij i j+ - yj * (ajNew - aj) * kij j j+ bNew | aiNew > 0 && aiNew < cC = b1+ | ajNew > 0 && ajNew < cC = b2+ | otherwise = (b1 + b2) / 2+ in (al', bNew, changed + 1)+ in foldl step (al0, b0, 0 :: Int) [0 .. n - 1]+ -- パスを回す: 変化無しが maxPasses 連続 or maxIter 到達で停止。+ loop !al !b !passes !iter+ | passes >= svmMaxPasses cfg || iter >= svmMaxIter cfg = (al, b)+ | otherwise =+ let (al', b', changed) = onePass (al, b)+ in if changed == 0 then loop al' b' (passes + 1) (iter + 1)+ else loop al' b' 0 (iter + 1)+ (alphaF, bF) = loop (VU.replicate n 0) 0 0 0+ -- α>0 のみ保持 (スパース SV)。+ svIdx = [ i | i <- [0 .. n - 1], alphaF VU.! i > 1e-8 ]+ svX = LA.fromRows [ LA.toRows x !! i | i <- svIdx ]+ svY = VU.fromList [ ys VU.! i | i <- svIdx ]+ svA = VU.fromList [ alphaF VU.! i | i <- svIdx ]+ in SVM { svmSVx = svX, svmSVy = svY, svmSVa = svA+ , svmB = bF, svmKern = svmKernel cfg+ , svmKParams = svmParams cfg }++-- | [日本語]: 決定値 f(x) = Σ_{SV} α_i y_i K(x_i, x) + b (各行)。+-- [English]: Decision value f(x) = Σ_{SV} α_i y_i K(x_i, x) + b (per row).+predictSVMScore :: SVM -> LA.Matrix Double -> VU.Vector Double+predictSVMScore m x =+ let svRows = LA.toRows (svmSVx m)+ nsv = length svRows+ ker = svmKern m+ kp = svmKParams m+ score xr = svmB m+ + sum [ svmSVa m VU.! s * svmSVy m VU.! s * kEvalMV ker kp (svRows !! s) xr+ | s <- [0 .. nsv - 1] ]+ in VU.fromList (map score (LA.toRows x))++-- | [日本語]: 予測ラベル {0,1} (score ≥ 0 → 1)。+-- [English]: Predicted label {0,1} (score ≥ 0 → 1).+predictSVM :: SVM -> LA.Matrix Double -> VU.Vector Int+predictSVM m x = VU.map (\s -> if s >= 0 then 1 else 0) (predictSVMScore m x)++-- ===========================================================================+-- 多クラス (one-vs-rest)+-- ===========================================================================++data SVMMulti = SVMMulti+ { svmmClasses :: ![Int]+ , svmmBinaries :: ![SVM] -- ^ [日本語]: クラス順に 1-vs-rest。 [English]: One-vs-rest binaries, in class order.+ , svmmClassNames :: ![Text] -- ^ [日本語]: クラス名 (df|-> が levels 注入・空=数値表示)。 [English]: Class names (injected as levels by df|->; empty means numeric display).+ } deriving (Show)++-- | [日本語]: 多クラス C-SVC (one-vs-rest・各 binary は 'fitSVM'・決定的)。+-- [English]: Multiclass C-SVC (one-vs-rest; each binary uses 'fitSVM'; deterministic).+fitSVMMulti :: SVMConfig -> LA.Matrix Double -> VU.Vector Int -> SVMMulti+fitSVMMulti cfg x y =+ let classes = sort (nub (VU.toList y))+ bins = [ fitSVM cfg x (VU.map (\yi -> if yi == c then 1 else 0) y)+ | c <- classes ]+ in SVMMulti { svmmClasses = classes, svmmBinaries = bins, svmmClassNames = [] }++-- | [日本語]: 各クラスの score 最大で分類。+-- [English]: Classify by the maximum score across classes.+predictSVMMulti :: SVMMulti -> LA.Matrix Double -> VU.Vector Int+predictSVMMulti m x =+ let classes = svmmClasses m+ scores = [ VU.toList (predictSVMScore b x) | b <- svmmBinaries m ]+ n = LA.rows x+ pick i = let col = [ (classes !! k, scores !! k !! i) | k <- [0 .. length classes - 1] ]+ in fst (maximumBy (comparing snd) col)+ in VU.fromList [ pick i | i <- [0 .. n - 1] ]++-- ===========================================================================+-- 自動最適化 (k-fold CV グリッド探索)+--+-- SVM は確率モデルでないため GP の周辺尤度最適化は使えない。 代わりに+-- **k-fold 交差検証の accuracy を最大化**する格子探索 (sklearn @GridSearchCV@ /+-- R `e1071::tune.svm` 相当)。 SMO は乱数不使用・fold 分割も固定 seed の+-- 'Hanalyze.Stat.CV.kFold' を 'runST' で回すため **完全に決定的**。+-- ===========================================================================++-- | [日本語]: ハイパラの決め方 (GP の @HyperStrategy@ と同型)。 固定値をそのまま使うか、+-- CV グリッドを探索して最良を選ぶか。 'SVMConfig' の @svmHyper@ に持たせ、 動詞 @svmCls@ が+-- これを見て分岐する (別動詞 @svmClsTuned@ は作らない)。+-- [English]: How hyperparameters are decided (mirrors GP's+-- @HyperStrategy@). Either use the fixed values as-is, or search a CV+-- grid and pick the best. Held in 'SVMConfig' as @svmHyper@, and the+-- verb @svmCls@ branches on it (no separate @svmClsTuned@ verb is made).+data SVMHyper+ = SVMFixed -- ^ [日本語]: 'SVMConfig' の C/kernel/params をそのまま使う。 [English]: Use 'SVMConfig''s C/kernel/params as-is.+ | SVMTuneCV SVMTuneGrid -- ^ [日本語]: グリッドを k-fold CV で探索し最良ハイパラで再学習。 [English]: Search the grid via k-fold CV and refit with the best hyperparameters.+ deriving (Show)++-- | [日本語]: SVM ハイパラ探索グリッド。 候補は C × kernel × ℓ の直積。+-- 'Linear' カーネルは ℓ を使わないので ℓ 軸は無視する (重複評価を避ける)。+-- [English]: SVM hyperparameter search grid. Candidates are the product+-- of C × kernel × ℓ. Since the 'Linear' kernel does not use ℓ, the ℓ+-- axis is ignored for it (to avoid duplicate evaluations).+data SVMTuneGrid = SVMTuneGrid+ { svmtCs :: ![Double] -- ^ [日本語]: 正則化 C 候補 (0 < C)。 [English]: Regularization C candidates (0 < C).+ , svmtKernels :: ![Kernel] -- ^ [日本語]: カーネル候補。 [English]: Kernel candidates.+ , svmtLengths :: ![Double] -- ^ [日本語]: 長さスケール ℓ 候補 (距離カーネル/Poly の γ=1/2ℓ²)。 [English]: Length-scale ℓ candidates (γ=1/2ℓ² for distance kernels/Poly).+ , svmtFolds :: !Int -- ^ [日本語]: CV fold 数 k (2 以上)。 [English]: Number of CV folds k (2 or more).+ } deriving (Show)++-- | [日本語]: 既定グリッド: C ∈ {0.1,1,10,100} × RBF × ℓ ∈ {0.25,0.5,1,2,4}・5-fold。+-- [English]: Default grid: C ∈ {0.1,1,10,100} × RBF × ℓ ∈ {0.25,0.5,1,2,4}, 5-fold.+defaultSVMTuneGrid :: SVMTuneGrid+defaultSVMTuneGrid = SVMTuneGrid+ { svmtCs = [0.1, 1, 10, 100]+ , svmtKernels = [RBF]+ , svmtLengths = [0.25, 0.5, 1, 2, 4]+ , svmtFolds = 5+ }++-- | [日本語]: グリッドの 1 点に対応する 'SVMConfig' を作る (base から C/kernel/ℓ を差し替え)。+-- [English]: Build the 'SVMConfig' for one grid point (swaps C/kernel/ℓ from base).+tuneCandidate :: SVMConfig -> Double -> Kernel -> Double -> SVMConfig+tuneCandidate base c ker l =+ base { svmC = c, svmKernel = ker+ , svmParams = (svmParams base) { kpLengthScale = l } }++-- | [日本語]: グリッドの全候補 'SVMConfig' (Linear は ℓ 軸を畳む)。+-- [English]: All candidate 'SVMConfig's from the grid (Linear collapses the ℓ axis).+tuneCandidates :: SVMConfig -> SVMTuneGrid -> [SVMConfig]+tuneCandidates base grid =+ [ tuneCandidate base c ker l+ | c <- svmtCs grid+ , ker <- svmtKernels grid+ , l <- lengthsFor ker ]+ where+ lengthsFor Linear = take 1 (svmtLengths grid ++ [1.0]) -- ℓ 無関係 → 1 点+ lengthsFor _ = svmtLengths grid++-- | [日本語]: 行添字リストで行列の行とラベルを抜き出す。+-- [English]: Extract matrix rows and labels by a list of row indices.+sliceRows :: V.Vector (LA.Vector Double) -> VU.Vector Int -> [Int]+ -> (LA.Matrix Double, VU.Vector Int)+sliceRows rows y idx =+ ( LA.fromRows [ rows V.! i | i <- idx ]+ , VU.fromList [ y VU.! i | i <- idx ] )++-- | [日本語]: 1 候補の平均 CV accuracy。 各 fold で train に学習し test の正解率を測る。+-- [English]: Average CV accuracy for one candidate. Fits on train and+-- measures test accuracy for each fold.+cvAccuracy :: SVMConfig -> [Fold]+ -> V.Vector (LA.Vector Double) -> VU.Vector Int -> Double+cvAccuracy cfg folds rows y =+ let accs = [ foldAcc tr te | (tr, te) <- folds, not (null te) ]+ foldAcc trIdx teIdx =+ let (xTr, yTr) = sliceRows rows y trIdx+ (xTe, yTe) = sliceRows rows y teIdx+ model = fitSVMMulti cfg xTr yTr+ pred = predictSVMMulti model xTe+ nTe = VU.length yTe+ ok = length [ () | i <- [0 .. nTe - 1], pred VU.! i == yTe VU.! i ]+ in fromIntegral ok / fromIntegral nTe+ in if null accs then 0 else sum accs / fromIntegral (length accs)++-- | [日本語]: k-fold CV で SVM のハイパラ (C × kernel × ℓ) を調律する。 CV accuracy を+-- 最大化する 'SVMConfig' と、 その平均 CV accuracy を返す。 __決定的__ (固定 seed の+-- fold 分割・SMO は乱数不使用)。 sklearn @GridSearchCV@ / R `tune.svm` 相当。+-- [English]: Tune the SVM hyperparameters (C × kernel × ℓ) via k-fold CV.+-- Returns the 'SVMConfig' that maximizes CV accuracy along with its mean+-- CV accuracy. __Deterministic__ (fixed-seed fold splits; SMO uses no+-- randomness). Equivalent to sklearn's @GridSearchCV@ \/ R's `tune.svm`.+tuneSVM :: SVMConfig -> SVMTuneGrid -> LA.Matrix Double -> VU.Vector Int+ -> (SVMConfig, Double)+tuneSVM base grid x y =+ let n = LA.rows x+ rows = V.fromList (LA.toRows x)+ k = max 2 (min (svmtFolds grid) n)+ -- 固定 seed の k-fold (決定的・再現可能)。+ folds = runST $ do+ gen <- MWC.initialize (V.singleton 42)+ kFold k n gen+ scored = [ (cfg, cvAccuracy cfg folds rows y)+ | cfg <- tuneCandidates base grid ]+ in maximumBy (comparing snd) scored
+ src/Hanalyze/Model/Spline.hs view
@@ -0,0 +1,241 @@+{-# 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+-- ---------------------------------------------------------------------------++-- | [日本語]: 自然三次スプライン基底 (両端点で 2 階微分が 0・境界外では線形)。+--+-- ノット 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)。+--+-- [English]: Natural cubic-spline basis (zero second derivative at the+-- boundaries; linear outside the boundary).+--+-- For knots K1 < K2 < ... < KN, N basis functions:+-- 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)+--+-- Output: a matrix (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
+ src/Hanalyze/Model/StateSpace.hs view
@@ -0,0 +1,160 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE BangPatterns #-}++-- |+-- Module : Hanalyze.Model.StateSpace+-- Description : 線形ガウス状態空間モデルの Kalman Filter / RTS Smoother+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- [日本語]: 線形ガウス状態空間モデル (Linear Gaussian State Space Model) ++-- Kalman Filter / RTS Smoother。+--+-- モデル:+--+-- @+-- x_t = F x_{t-1} + w_t, w_t ~ N(0, Q)+-- y_t = H x_t + v_t, v_t ~ N(0, R)+-- @+--+-- - 'kalmanFilter' は前向きフィルタリングで filtered mean / cov を計算し、+-- 同時に innovation 系列の対数尤度 (= モデル尤度) を返す。+-- - 'kalmanSmoother' は RTS (Rauch-Tung-Striebel) で smoothed mean / cov を+-- 後ろ向きに計算。 入力に既にフィルタ済の 'KalmanResult' を渡す。+--+-- すべて hmatrix Vector / Matrix で実装 (list 化禁止)。+--+-- [English]: The Linear Gaussian State Space Model plus a Kalman Filter \/+-- RTS Smoother.+--+-- Model:+--+-- @+-- x_t = F x_{t-1} + w_t, w_t ~ N(0, Q)+-- y_t = H x_t + v_t, v_t ~ N(0, R)+-- @+--+-- - 'kalmanFilter' computes the filtered mean \/ covariance via forward+-- filtering, and simultaneously returns the log-likelihood of the+-- innovation sequence (= the model likelihood).+-- - 'kalmanSmoother' computes the smoothed mean \/ covariance backward via+-- RTS (Rauch-Tung-Striebel). It takes an already-filtered 'KalmanResult'+-- as input.+--+-- Implemented entirely with hmatrix Vector \/ Matrix (converting to lists is+-- forbidden).+module Hanalyze.Model.StateSpace+ ( StateSpaceModel (..)+ , KalmanResult (..)+ , kalmanFilter+ , kalmanSmoother+ ) where++import qualified Numeric.LinearAlgebra as LA++-- ===========================================================================+-- 型+-- ===========================================================================++data StateSpaceModel = StateSpaceModel+ { ssF :: !(LA.Matrix Double) -- ^ [日本語]: 状態遷移行列 F (n_x × n_x)。 [English]: The state transition matrix F (n_x × n_x).+ , ssH :: !(LA.Matrix Double) -- ^ [日本語]: 観測行列 H (n_y × n_x)。 [English]: The observation matrix H (n_y × n_x).+ , ssQ :: !(LA.Matrix Double) -- ^ [日本語]: プロセスノイズ共分散 Q (n_x × n_x)。 [English]: The process noise covariance Q (n_x × n_x).+ , ssR :: !(LA.Matrix Double) -- ^ [日本語]: 観測ノイズ共分散 R (n_y × n_y)。 [English]: The observation noise covariance R (n_y × n_y).+ , ssX0 :: !(LA.Vector Double) -- ^ [日本語]: 初期状態 (n_x)。 [English]: The initial state (n_x).+ , ssP0 :: !(LA.Matrix Double) -- ^ [日本語]: 初期共分散 (n_x × n_x)。 [English]: The initial covariance (n_x × n_x).+ } deriving (Show)++data KalmanResult = KalmanResult+ { krFilteredMean :: ![LA.Vector Double]+ , krFilteredCov :: ![LA.Matrix Double]+ , krSmoothedMean :: ![LA.Vector Double]+ -- ^ [日本語]: 'kalmanFilter' のみ呼んだ場合は空。 'kalmanSmoother' を通すと埋まる。+ -- [English]: Empty if only 'kalmanFilter' was called. Populated once+ -- passed through 'kalmanSmoother'.+ , krSmoothedCov :: ![LA.Matrix Double]+ , krLogLik :: !Double -- ^ [日本語]: Σ log p(y_t | y_{1:t-1})。 [English]: Σ log p(y_t | y_{1:t-1}).+ } deriving (Show)++-- ===========================================================================+-- Kalman Filter (forward pass)+-- ===========================================================================++-- | [日本語]: 観測系列 ys (各列が 1 時点の観測ベクトル) からフィルタリング。+-- ys の行 = 観測次元 n_y、 列 = 時点数 T。+-- [English]: Filters from an observation sequence ys (each column is one+-- time point's observation vector). ys's rows = observation dimension+-- n_y, columns = number of time points T.+kalmanFilter :: StateSpaceModel -> LA.Matrix Double -> KalmanResult+kalmanFilter ssm ys =+ let nY = LA.rows ys+ _ = nY :: Int+ tT = LA.cols ys+ f = ssF ssm+ h = ssH ssm+ q = ssQ ssm+ r = ssR ssm+ step (x, p, accM, accP, ll) t =+ let yt = LA.flatten (ys LA.¿ [t])+ -- predict+ xPred = f LA.#> x+ pPred = f LA.<> p LA.<> LA.tr f + q+ -- update+ yPred = h LA.#> xPred+ sInn = h LA.<> pPred LA.<> LA.tr h + r+ -- guard against singular S+ sInv = LA.inv sInn+ gain = pPred LA.<> LA.tr h LA.<> sInv+ inn = yt - yPred+ xNew = xPred + gain LA.#> inn+ pNew = pPred - gain LA.<> h LA.<> pPred+ -- log-likelihood contribution+ nY_ = fromIntegral (LA.size inn) :: Double+ detS = LA.det sInn+ quad = inn `LA.dot` (sInv LA.#> inn)+ lt = -0.5 * (nY_ * log (2 * pi) + log (max 1e-300 detS) + quad)+ in (xNew, pNew, accM ++ [xNew], accP ++ [pNew], ll + lt)+ (_, _, ms, ps, llTotal) =+ foldl step (ssX0 ssm, ssP0 ssm, [], [], 0) [0 .. tT - 1]+ in KalmanResult+ { krFilteredMean = ms+ , krFilteredCov = ps+ , krSmoothedMean = []+ , krSmoothedCov = []+ , krLogLik = llTotal+ }++-- ===========================================================================+-- RTS Smoother (backward pass)+-- ===========================================================================++-- | [日本語]: RTS smoother。 'kalmanFilter' の出力を受け取り smoothed * を埋めて返す。+-- [English]: The RTS smoother. Takes the output of 'kalmanFilter' and+-- returns it with the smoothed * fields filled in.+kalmanSmoother :: StateSpaceModel -> KalmanResult -> KalmanResult+kalmanSmoother ssm kr =+ let f = ssF ssm+ q = ssQ ssm+ ms = krFilteredMean kr+ ps = krFilteredCov kr+ tT = length ms+ -- 末尾は filtered と smoothed が同じ+ mTLast = last ms+ pTLast = last ps+ -- 後ろから前へ走査+ step (smMs, smPs) i =+ let mFilt = ms !! i+ pFilt = ps !! i+ mPred = f LA.#> mFilt+ pPred = f LA.<> pFilt LA.<> LA.tr f + q+ mNext = head smMs+ pNext = head smPs+ g = pFilt LA.<> LA.tr f LA.<> LA.inv pPred+ mNew = mFilt + g LA.#> (mNext - mPred)+ pNew = pFilt + g LA.<> (pNext - pPred) LA.<> LA.tr g+ in (mNew : smMs, pNew : smPs)+ (smMsFinal, smPsFinal) =+ foldl step ([mTLast], [pTLast]) (reverse [0 .. tT - 2])+ in kr { krSmoothedMean = smMsFinal+ , krSmoothedCov = smPsFinal+ }
+ src/Hanalyze/Model/Survival.hs view
@@ -0,0 +1,431 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE BangPatterns #-}++-- |+-- Module : Hanalyze.Model.Survival+-- Description : 打ち切りを伴う生存時間解析 (Kaplan-Meier / Nelson-Aalen / log-rank / Cox PH)+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- Survival analysis.+--+-- Time-to-event analysis under right censoring. Implements:+--+-- * 'kaplanMeier' — non-parametric survival function estimator.+-- * 'nelsonAalen' — non-parametric cumulative hazard estimator.+-- * 'logRankTest' — compare survival between groups.+-- * 'coxPH' — Cox proportional hazards regression.+--+-- == Convention+--+-- A "survival" sample is @(time, event)@ where @time@ is duration and+-- @event ∈ {0, 1}@: @1@ = event observed (death, failure, etc.),+-- @0@ = censored (still alive at study end / dropout). All functions+-- accept the convention via @SurvSample@ records.+module Hanalyze.Model.Survival+ ( -- * Common types+ SurvSample (..)+ , Event (..)+ -- * Non-parametric estimators+ , KMResult (..)+ , kaplanMeier+ , NAResult (..)+ , nelsonAalen+ -- * Hypothesis tests+ , LogRankResult (..)+ , logRankTest+ -- * Cox proportional hazards+ , CoxFit (..)+ , coxPH+ , coxBaselineHazard+ ) where++import qualified Numeric.LinearAlgebra as LA+import qualified Statistics.Distribution as SD+import qualified Statistics.Distribution.ChiSquared as ChiSq+import qualified Data.Vector as V+import qualified Data.Vector.Unboxed as VU+import qualified Data.Vector.Storable as VS+import Data.List (sort, sortBy, group)+import Data.Ord (comparing)++-- ---------------------------------------------------------------------------+-- Common types+-- ---------------------------------------------------------------------------++-- | Event indicator.+data Event = Censored | Observed deriving (Show, Eq, Ord)++-- | A single observation: @(time, event)@.+data SurvSample = SurvSample+ { ssTime :: !Double+ , ssEvent :: !Event+ } deriving (Show, Eq)++-- ---------------------------------------------------------------------------+-- Kaplan-Meier+-- ---------------------------------------------------------------------------++-- | Kaplan-Meier survival function estimator.+data KMResult = KMResult+ { kmrTimes :: ![Double] -- ^ Distinct event times.+ , kmrSurvival :: ![Double] -- ^ Ŝ(t) at each event time.+ , kmrAtRisk :: ![Int] -- ^ Number at risk just before t_i.+ , kmrEvents :: ![Int] -- ^ Number of events at t_i.+ , kmrCensored :: ![Int] -- ^ Number censored at t_i.+ } deriving (Show)++-- | Compute the Kaplan-Meier estimator.+--+-- @Ŝ(t_i) = Π_{j ≤ i} (1 − d_j / n_j)@ where @d_j@ is events at @t_j@+-- and @n_j@ is the number at risk just before @t_j@.+--+-- B9c: rewritten with a single sorted-vector pass + linear run-length+-- grouping (no @[s | s <- ss, ssTime s == t]@ filter for each time,+-- which was @O(n × distinct_times)@). On the n=2000 bench this drops+-- KM from ~33 ms to a few ms.+kaplanMeier :: [SurvSample] -> KMResult+kaplanMeier samples =+ let !sorted = sortBy (comparing ssTime) samples+ !n0 = length sorted+ groups = runLengthGroups sorted+ -- 累積生存は **先頭から** 積む: Ŝ(tᵢ) = ∏_{j ≤ i} (1 − dⱼ/nⱼ)。+ -- (旧実装は rest を先に再帰して右から積んでおり、 最終時点の (1−dⱼ/nⱼ)=0 が+ -- 全時点を 0 に潰す逆順バグだった。 計測で確認・修正。)+ go _ _ [] = ([], [], [], [], [])+ go !nAt !sAcc ((t, dj, cj) : rest) =+ let !sFactor = if nAt > 0+ then 1 - fromIntegral dj / fromIntegral nAt+ else 1+ !sNew = sAcc * sFactor+ (ts, ss, ns, ds, cs) = go (nAt - dj - cj) sNew rest+ in (t : ts, sNew : ss, nAt : ns, dj : ds, cj : cs)+ (ts, ss, ns, ds, cs) = go n0 1.0 groups+ in KMResult ts ss ns ds cs++-- | Walk a list pre-sorted by 'ssTime' and return per-distinct-time+-- @(time, num_events, num_censored)@ tuples.+runLengthGroups :: [SurvSample] -> [(Double, Int, Int)]+runLengthGroups [] = []+runLengthGroups (x:xs) = go (ssTime x) (countOf x) xs+ where+ countOf s = case ssEvent s of+ Observed -> (1 :: Int, 0 :: Int)+ Censored -> (0, 1)+ go !t (!d, !c) [] = [(t, d, c)]+ go !t (!d, !c) (s:rest)+ | ssTime s == t =+ let (di, ci) = countOf s+ in go t (d + di, c + ci) rest+ | otherwise =+ let (di, ci) = countOf s+ in (t, d, c) : go (ssTime s) (di, ci) rest++-- | Backwards-compatible export of the old @groupByTime@ API. Builds+-- on the new run-length walk for performance.+groupByTime :: [SurvSample] -> [(Double, [SurvSample], [SurvSample])]+groupByTime samples =+ let !sorted = sortBy (comparing ssTime) samples+ walk [] = []+ walk (s:rest) = collect (ssTime s) [s] rest+ collect t acc [] = [emit t acc]+ collect t acc (x:xs)+ | ssTime x == t = collect t (x:acc) xs+ | otherwise = emit t acc : collect (ssTime x) [x] xs+ emit t bucket =+ let (evs, cns) = splitByEvent bucket+ in (t, evs, cns)+ splitByEvent = foldr step ([], [])+ where step s (es, cs) = case ssEvent s of+ Observed -> (s : es, cs)+ Censored -> (es, s : cs)+ in walk sorted++-- ---------------------------------------------------------------------------+-- Nelson-Aalen+-- ---------------------------------------------------------------------------++-- | Nelson-Aalen cumulative hazard estimator.+data NAResult = NAResult+ { narTimes :: ![Double]+ , narCumHazard :: ![Double] -- ^ Ĥ(t) = Σ_j d_j / n_j.+ , narAtRisk :: ![Int]+ , narEvents :: ![Int]+ } deriving (Show)++-- | Compute the Nelson-Aalen estimator.+nelsonAalen :: [SurvSample] -> NAResult+nelsonAalen samples =+ let km = kaplanMeier samples+ ts = kmrTimes km+ ns = kmrAtRisk km+ ds = kmrEvents km+ hazardIncrements = [fromIntegral d / fromIntegral n | (n, d) <- zip ns ds]+ cumH = scanl1 (+) hazardIncrements+ in NAResult ts cumH ns ds++-- ---------------------------------------------------------------------------+-- Log-rank test+-- ---------------------------------------------------------------------------++-- | Log-rank test result.+data LogRankResult = LogRankResult+ { lrChi2 :: !Double+ , lrDf :: !Int+ , lrPValue :: !Double+ , lrGroupSizes :: ![Int]+ } deriving (Show)++-- | Log-rank test for comparing survival across @k@ groups.+--+-- Tests @H_0: S_1(t) = S_2(t) = ⋯ = S_k(t)@ for all @t@. Asymptotic+-- chi-square approximation with @k − 1@ degrees of freedom.+logRankTest :: [[SurvSample]] -> LogRankResult+logRankTest groups =+ let k = length groups+ ns = map length groups+ -- Pool all samples with group labels.+ labelled = concat+ [ [(g, s) | s <- ss] | (g, ss) <- zip [0 :: Int ..] groups ]+ sorted = sortBy (comparing (ssTime . snd)) labelled+ times = map head (group (map (ssTime . snd) sorted))+ -- For each time t_j, compute observed events O_{ij} per group i+ -- and expected events E_{ij} = (n_{ij} / n_j) × d_j, where+ -- n_{ij} = at risk in group i, n_j = total at risk, d_j = total events.+ go _ _ [] acc = acc+ go nAtRiskBy nAtRiskTotal (t : tRest) acc =+ let -- Events / censored at this time, by group.+ atTime = [s | s <- sorted, ssTime (snd s) == t]+ eventsByGrp = [ length [() | (g, s) <- atTime,+ g == i, ssEvent s == Observed]+ | i <- [0 .. k - 1] ]+ censoredByGrp = [ length [() | (g, s) <- atTime,+ g == i, ssEvent s == Censored]+ | i <- [0 .. k - 1] ]+ dTotal = sum eventsByGrp+ cTotal = sum censoredByGrp+ -- Expected events per group at this time.+ expected = [ if nAtRiskTotal > 0+ then fromIntegral nij * fromIntegral dTotal+ / fromIntegral nAtRiskTotal+ else 0+ | nij <- nAtRiskBy ]+ -- Variance contribution to each group's (O - E):+ -- v_{ij} = n_{ij}(n_j - n_{ij}) d_j (n_j - d_j) / (n_j² (n_j - 1))+ varContrib =+ if nAtRiskTotal > 1 && dTotal > 0+ then [ let nij = fromIntegral nij_i :: Double+ nj = fromIntegral nAtRiskTotal :: Double+ dj = fromIntegral dTotal :: Double+ in nij * (nj - nij) * dj * (nj - dj)+ / (nj * nj * (nj - 1))+ | nij_i <- nAtRiskBy ]+ else replicate k 0+ (oeAcc, varAcc) = acc+ oeNew = zipWith3 (\o e prev -> prev + (fromIntegral o - e))+ eventsByGrp expected oeAcc+ varNew = zipWith (+) varAcc varContrib+ -- Update at-risk counts (subtract events + censored).+ nAtRiskBy' = zipWith3 (\nrij ej cj -> nrij - ej - cj)+ nAtRiskBy eventsByGrp censoredByGrp+ in go nAtRiskBy' (nAtRiskTotal - dTotal - cTotal) tRest (oeNew, varNew)+ (oeFinal, varFinal) = go ns (sum ns) times+ (replicate k 0, replicate k 0)+ -- Test statistic: (O - E)² / Var summed (approx for k=2);+ -- for general k, use first (k-1) components.+ chi2 =+ if k == 2+ then case (oeFinal, varFinal) of+ ([o1, _], [v1, _]) | v1 > 0 -> o1 * o1 / v1+ _ -> 0+ else+ -- General case: sum of squared standardised (O - E).+ sum [ if v > 0 then o * o / v else 0+ | (o, v) <- zip oeFinal varFinal ]+ df = k - 1+ pVal = SD.complCumulative (ChiSq.chiSquared df) chi2+ in LogRankResult+ { lrChi2 = chi2+ , lrDf = df+ , lrPValue = pVal+ , lrGroupSizes = ns+ }++-- ---------------------------------------------------------------------------+-- Cox proportional hazards+-- ---------------------------------------------------------------------------++-- | Cox PH model fit.+data CoxFit = CoxFit+ { coxBeta :: !(LA.Vector Double) -- ^ Coefficients.+ , coxSE :: !(LA.Vector Double) -- ^ Standard errors.+ , coxLogLik :: !Double -- ^ Log partial likelihood.+ , coxIters :: !Int -- ^ Newton iterations.+ } deriving (Show)++-- | Fit Cox proportional hazards by maximising the partial likelihood+-- via Newton-Raphson.+--+-- Partial likelihood (ties handled by Breslow approximation):+--+-- @L(β) = Π_i exp(β·x_i) / Σ_{j ∈ R(t_i)} exp(β·x_j)@+--+-- where @R(t_i)@ is the risk set at time @t_i@.+coxPH+ :: [LA.Vector Double] -- ^ Covariates per sample.+ -> [SurvSample] -- ^ Times and events.+ -> CoxFit+--+-- B9c: list operations (@scanr1@, @!!@, list comprehensions over+-- 'LA.Vector') replaced with @VS@/@V@-vector reverse cumulative sums+-- and a precomputed boxed 'V.Vector' of risk-set rows. The score and+-- gradient now run in @O(n p)@ per call (no per-index list traversal).+-- Hessian remains numerical for now (algorithmic Hessian is a future+-- improvement) but each finite-difference call is now cheap.+coxPH xs samples =+ let !n = length xs+ !p = if n == 0 then 0 else LA.size (head xs)+ !indexed = zip xs samples+ !sortedByTime = sortBy (comparing (ssTime . snd)) indexed+ -- Event indices as an unboxed Vector for fast iteration.+ !eventIdxsV = VU.fromList+ [ i | (i, (_, s)) <- zip [0 :: Int ..] sortedByTime+ , ssEvent s == Observed ]+ !xsArr = LA.fromRows (map fst sortedByTime)+ !xsRows = V.fromList (LA.toRows xsArr) -- O(1) indexing++ -- Score vector at β: X β. Storable for VS.scanr1.+ scoresV beta = LA.flatten (xsArr LA.<> LA.asColumn beta) :: VS.Vector Double++ -- Reverse cumulative sum on Storable: out[i] = Σ_{j≥i} v[j].+ revCumSum :: VS.Vector Double -> VS.Vector Double+ revCumSum = VS.fromList . scanr1 (+) . VS.toList+ -- (Acceptable: VS.toList -> scanr1 -> VS.fromList is O(n) and+ -- runs once per gradAndHess; the dominant cost is the BLAS GEMV+ -- and per-row work below.)++ -- log-partial-likelihood at β.+ logLik beta =+ let scs = scoresV beta+ !expS = VS.map exp scs+ !cumE = revCumSum expS+ walk acc k+ | k >= VU.length eventIdxsV = acc+ | otherwise =+ let !i = VU.unsafeIndex eventIdxsV k+ !s = VS.unsafeIndex scs i+ !c = VS.unsafeIndex cumE i+ in walk (acc + s - log c) (k + 1)+ in walk (0 :: Double) 0++ -- Gradient of log partial likelihood w.r.t. β.+ gradAt beta =+ let scs = scoresV beta+ !expS = VS.map exp scs+ !cumE = revCumSum expS+ -- Weighted X: rows scaled by exp(score). Then row-wise+ -- reverse cumulative sum (per column) gives Σ_{j≥i} e_j x_j.+ !weightedRows = V.zipWith+ (\x e -> LA.scale e x) xsRows+ (V.fromList (VS.toList expS))+ -- Reverse cumulative sum of vectors:+ !cumWeighted = revCumSumVecV (LA.konst 0 p) weightedRows+ walk acc k+ | k >= VU.length eventIdxsV = acc+ | otherwise =+ let !i = VU.unsafeIndex eventIdxsV k+ !ri = xsRows V.! i+ !ci = VS.unsafeIndex cumE i+ !wi = cumWeighted V.! i+ !contrib = ri - LA.scale (1 / ci) wi+ in walk (acc + contrib) (k + 1)+ in walk (LA.konst 0 p) 0++ maxIter = 25 :: Int+ tol = 1e-6+ h = 1e-5++ -- Numerical Hessian column i (central difference of grad).+ hessCol betaList i =+ let bp = LA.fromList [if k == i then v + h else v+ | (k, v) <- zip [0::Int ..] betaList]+ bm = LA.fromList [if k == i then v - h else v+ | (k, v) <- zip [0::Int ..] betaList]+ in LA.scale (1 / (2 * h)) (gradAt bp - gradAt bm)++ step beta =+ let !g = gradAt beta+ !bL = LA.toList beta+ !hessian = LA.fromRows [hessCol bL i | i <- [0 .. p - 1]]+ !negH = LA.scale (-1) hessian+ !delta = negH LA.<\> g+ !betaNew = beta + delta+ !converged = LA.norm_2 delta < tol+ in (betaNew, converged)++ loop !i beta+ | i >= maxIter = (beta, i)+ | otherwise =+ let (beta', conv) = step beta+ in if conv then (beta', i + 1)+ else loop (i + 1) beta'++ (!betaFinal, !iters) = loop 0 (LA.konst 0 p)++ -- Final Hessian for SEs.+ !bFL = LA.toList betaFinal+ !hessFinal = LA.fromRows [hessCol bFL i | i <- [0 .. p - 1]]+ !negHFinal = LA.scale (-1) hessFinal+ !seVec = case maybeInverse negHFinal of+ Just inv -> LA.cmap sqrt (LA.takeDiag inv)+ Nothing -> LA.konst (1/0) p+ in CoxFit+ { coxBeta = betaFinal+ , coxSE = seVec+ , coxLogLik = logLik betaFinal+ , coxIters = iters+ }++-- | Reverse cumulative sum over a boxed Vector of 'LA.Vector Double':+-- @out[i] = Σ_{j≥i} v[j]@. Returns a Vector of the same length.+-- Uses 'scanr' once (O(n p)) — total cost dominated by BLAS-bound+-- vector additions.+revCumSumVecV :: LA.Vector Double+ -> V.Vector (LA.Vector Double)+ -> V.Vector (LA.Vector Double)+revCumSumVecV zeroV vs =+ -- scanr produces length n+1 with a trailing zero seed; drop it.+ let !suf = scanr (+) zeroV (V.toList vs)+ in V.fromList (init suf)++-- | Baseline cumulative hazard (Breslow estimator).+coxBaselineHazard+ :: CoxFit+ -> [LA.Vector Double]+ -> [SurvSample]+ -> [(Double, Double)] -- ^ @(t_i, Ĥ_0(t_i))@.+coxBaselineHazard fit xs samples =+ let beta = coxBeta fit+ indexed = zip xs samples+ sortedByTime = sortBy (comparing (ssTime . snd)) indexed+ times = sort (map (ssTime . snd) sortedByTime)+ uniqueTs = map head (group times)+ atRiskAt t =+ [ x | (x, s) <- sortedByTime, ssTime s >= t ]+ eventsAt t =+ length [() | (_, s) <- sortedByTime, ssTime s == t,+ ssEvent s == Observed]+ hazardIncrements t =+ let denom = sum [ exp (LA.dot beta x) | x <- atRiskAt t ]+ d = eventsAt t+ in if denom > 0 then fromIntegral d / denom else 0+ hi = map hazardIncrements uniqueTs+ cumH = scanl1 (+) hi+ in zip uniqueTs cumH++-- | Try to compute the inverse of a matrix; returns Nothing if singular.+maybeInverse :: LA.Matrix Double -> Maybe (LA.Matrix Double)+maybeInverse m =+ case LA.rank m of+ r | r == LA.rows m -> Just (LA.inv m)+ | otherwise -> Nothing
+ src/Hanalyze/Model/TimeSeries.hs view
@@ -0,0 +1,482 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE BangPatterns #-}++-- |+-- Module : Hanalyze.Model.TimeSeries+-- Description : AR/MA/ARIMA・指数平滑・STL 分解を含む時系列モデリング一式+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- Time-series modelling.+--+-- @+-- import Hanalyze.Model.TimeSeries+--+-- let acf = autocorrelation 20 ys+-- fit = fitAR 2 ys -- AR(2) by Yule-Walker+-- fc = forecastAR fit ys 10 -- 10-step ahead+--+-- let hw = holtWinters HWAdditive 12 ys+-- fc2 = hwForecast hw 24+-- @+--+-- == Implemented+--+-- * 'autocorrelation' / 'partialAutocorrelation' (sample ACF / PACF)+-- * 'fitAR' / 'forecastAR' (autoregressive AR(p) via Yule-Walker)+-- * 'fitMA' / 'forecastMA' (moving-average MA(q) via innovations)+-- * 'differencing' / 'inverseDifferencing' (helpers for ARIMA d)+-- * 'fitARIMA' / 'forecastARIMA' (ARIMA(p, d, q))+-- * 'simpleExpSmoothing' (single exp smoothing)+-- * 'holtWinters' (triple exp smoothing, additive / multiplicative)+-- * 'movingAverage' (centred / trailing)+-- * 'stlDecompose' (STL — seasonal-trend decomposition, simplified)+module Hanalyze.Model.TimeSeries+ ( -- * ACF / PACF+ autocorrelation+ , partialAutocorrelation+ -- * AR+ , ARFit (..)+ , fitAR+ , forecastAR+ -- * MA+ , MAFit (..)+ , fitMA+ , forecastMA+ -- * ARIMA+ , ARIMAFit (..)+ , fitARIMA+ , forecastARIMA+ , differencing+ , inverseDifferencing+ -- * Exponential smoothing+ , simpleExpSmoothing+ , HWMode (..)+ , HWFit (..)+ , holtWinters+ , hwForecast+ -- * Helpers+ , movingAverage+ , stlDecompose+ ) where++import qualified Numeric.LinearAlgebra as LA++-- ---------------------------------------------------------------------------+-- ACF / PACF+-- ---------------------------------------------------------------------------++-- | Sample autocorrelation function up to @maxLag@. Lag 0 is always+-- @1.0@. Computed as @r_k = c_k / c_0@ with biased autocovariance:+-- @c_k = (1/n) Σ_{t=0..n-k-1} (y_t - ȳ)(y_{t+k} - ȳ)@.+autocorrelation+ :: Int -- ^ Maximum lag.+ -> LA.Vector Double+ -> LA.Vector Double+autocorrelation maxLag y =+ let n = LA.size y+ ybar = LA.sumElements y / fromIntegral n+ ydev = y - LA.scalar ybar+ c0 = LA.dot ydev ydev / fromIntegral n+ cAt k = sum [ LA.atIndex ydev t * LA.atIndex ydev (t + k)+ | t <- [0 .. n - k - 1] ]+ / fromIntegral n+ rs = [ if c0 == 0 then 0 else cAt k / c0+ | k <- [0 .. maxLag] ]+ in LA.fromList rs++-- | Sample partial autocorrelation function up to @maxLag@ via direct+-- AR-fit: PACF[k] = last AR coefficient when fitting AR(k) by+-- Yule-Walker. Conceptually equivalent to the Durbin-Levinson+-- recursion but easier to implement correctly.+partialAutocorrelation+ :: Int+ -> LA.Vector Double+ -> LA.Vector Double+partialAutocorrelation maxLag y =+ let pacfAt 0 = 1+ pacfAt k =+ let fit = fitAR k y+ phi = arPhi fit+ in if LA.size phi == 0 then 0+ else LA.atIndex phi (k - 1)+ in LA.fromList [pacfAt k | k <- [0 .. maxLag]]++-- ---------------------------------------------------------------------------+-- AR (autoregressive)+-- ---------------------------------------------------------------------------++-- | Fitted AR(p) model.+data ARFit = ARFit+ { arOrder :: !Int -- ^ p+ , arPhi :: !(LA.Vector Double) -- ^ AR coefficients (length p)+ , arIntercept :: !Double -- ^ μ (mean)+ , arResidVar :: !Double -- ^ Innovation variance.+ } deriving (Show)++-- | Fit an AR(p) model by the Yule-Walker equations.+-- Solves @R φ = r@ where @R@ is the @p × p@ Toeplitz matrix of+-- autocovariances and @r = (γ_1, …, γ_p)@.+fitAR :: Int -> LA.Vector Double -> ARFit+fitAR p y =+ let n = LA.size y+ ybar = LA.sumElements y / fromIntegral n+ yC = y - LA.scalar ybar+ gamma k = LA.dot (LA.subVector 0 (n - k) yC)+ (LA.subVector k (n - k) yC) / fromIntegral n+ rhs = LA.fromList [gamma k | k <- [1 .. p]]+ mat = LA.fromLists+ [[gamma (abs (i - j)) | j <- [0 .. p - 1]]+ | i <- [0 .. p - 1]]+ phi = mat LA.<\> rhs+ -- Innovation variance via Yule-Walker:+ -- σ² = γ_0 - Σ φ_i γ_i+ innovVar = gamma 0 - LA.dot phi rhs+ in ARFit+ { arOrder = p+ , arPhi = phi+ , arIntercept = ybar+ , arResidVar = max 0 innovVar+ }++-- | Forecast @h@ steps ahead from a fitted AR model and the most+-- recent observations (in chronological order).+forecastAR+ :: ARFit+ -> LA.Vector Double -- ^ History (must be ≥ p).+ -> Int -- ^ Horizon h.+ -> LA.Vector Double+forecastAR fit hist h =+ let p = arOrder fit+ mu = arIntercept fit+ phi = arPhi fit+ lastP = LA.toList (LA.subVector (LA.size hist - p) p hist)+ go _ acc 0 = reverse acc+ go window acc k =+ let dev = zipWith (-) window (replicate p mu)+ yHat = mu + LA.dot phi (LA.fromList dev)+ window' = drop 1 window ++ [yHat]+ in go window' (yHat : acc) (k - 1)+ in LA.fromList (go lastP [] h)++-- ---------------------------------------------------------------------------+-- MA (moving average)+-- ---------------------------------------------------------------------------++-- | Fitted MA(q) model.+data MAFit = MAFit+ { maOrder :: !Int+ , maTheta :: !(LA.Vector Double) -- ^ MA coefficients (length q)+ , maIntercept :: !Double+ , maResidVar :: !Double+ , maResiduals :: !(LA.Vector Double) -- ^ Innovation series.+ } deriving (Show)++-- | Fit an MA(q) model via the innovations algorithm (Brockwell-Davis+-- 1991, §5.2). Returns the estimated θ_i and innovation series.+fitMA :: Int -> LA.Vector Double -> MAFit+fitMA q y =+ let n = LA.size y+ ybar = LA.sumElements y / fromIntegral n+ yC = y - LA.scalar ybar+ gamma k = LA.dot (LA.subVector 0 (n - k) yC)+ (LA.subVector k (n - k) yC) / fromIntegral n+ -- Innovations algorithm: recursion+ -- v_n = γ_0+ -- θ_{n,n-k} = (γ_{n-k} - Σ_{j=0}^{k-1} θ_{n,n-j} θ_{k,k-j} v_j) / v_k+ -- v_n = γ_0 - Σ_{j=0}^{n-1} θ_{n,n-j}² v_j+ --+ -- We compute up to lag q.+ theta = LA.konst 0 q :: LA.Vector Double+ _ = theta+ -- Simplified approximation: use sample autocovariances directly+ -- to estimate θ via least squares (Hannan-Rissanen 1982).+ -- This is less accurate than full Innovations but simpler.+ thetaSimple = LA.fromList [ gamma k / max 1e-15 (gamma 0)+ | k <- [1 .. q] ]+ -- Compute residuals: e_t = y_t - μ - Σ θ_i e_{t-i}+ residuals = computeMAResiduals (LA.toList yC) (LA.toList thetaSimple)+ sigma2 = sum [r * r | r <- residuals] / fromIntegral n+ in MAFit+ { maOrder = q+ , maTheta = thetaSimple+ , maIntercept = ybar+ , maResidVar = sigma2+ , maResiduals = LA.fromList residuals+ }+ where+ computeMAResiduals :: [Double] -> [Double] -> [Double]+ computeMAResiduals ys thetas =+ let go acc [] = reverse acc+ go acc (yi:ys') =+ let q' = length thetas+ eHist = take q' acc -- recent residuals+ pad = replicate (q' - length eHist) 0+ ePadded = pad ++ eHist+ yHat = sum (zipWith (*) thetas (reverse ePadded))+ eNew = yi - yHat+ in go (eNew : acc) ys'+ in go [] ys++-- | Forecast h steps from MA(q). Beyond q steps, the forecast equals+-- the mean (innovations are zero in expectation).+forecastMA :: MAFit -> Int -> LA.Vector Double+forecastMA fit h =+ let q = maOrder fit+ theta = LA.toList (maTheta fit)+ mu = maIntercept fit+ eHist = LA.toList (maResiduals fit)+ eRecent = take q (reverse eHist)+ go k+ | k > q || k > h = []+ | otherwise =+ let pad = replicate (q - length eRecent) 0+ eP = pad ++ eRecent+ yhat = mu + sum (zipWith (*) theta (drop (k - 1) (reverse eP)))+ in yhat : go (k + 1)+ truncated = take h (go 1 ++ repeat mu)+ in LA.fromList truncated++-- ---------------------------------------------------------------------------+-- ARIMA+-- ---------------------------------------------------------------------------++-- | Fitted ARIMA(p, d, q) model.+data ARIMAFit = ARIMAFit+ { arimaP :: !Int+ , arimaD :: !Int+ , arimaQ :: !Int+ , arimaAR :: !ARFit+ , arimaMA :: !MAFit+ , arimaOrigSeries :: !(LA.Vector Double)+ } deriving (Show)++-- | Fit ARIMA(p, d, q): difference d times, then fit AR(p) + MA(q) on+-- the differenced series. Uses two-stage estimation (AR first, then+-- MA on residuals).+fitARIMA :: Int -> Int -> Int -> LA.Vector Double -> ARIMAFit+fitARIMA p d q y =+ let yDiff = iterate differencing y !! d+ arFit = fitAR p yDiff+ arResid = computeARResiduals arFit yDiff+ maFit = fitMA q arResid+ in ARIMAFit+ { arimaP = p+ , arimaD = d+ , arimaQ = q+ , arimaAR = arFit+ , arimaMA = maFit+ , arimaOrigSeries = y+ }++computeARResiduals :: ARFit -> LA.Vector Double -> LA.Vector Double+computeARResiduals fit y =+ let p = arOrder fit+ mu = arIntercept fit+ phi = LA.toList (arPhi fit)+ n = LA.size y+ ys = LA.toList y+ go i+ | i < p = 0+ | otherwise =+ let dev = [ys !! (i - k - 1) - mu | k <- [0 .. p - 1]]+ yHat = mu + sum (zipWith (*) phi dev)+ in (ys !! i) - yHat+ residuals = [go i | i <- [0 .. n - 1]]+ in LA.fromList residuals++-- | Forecast h steps from a fitted ARIMA model.+forecastARIMA :: ARIMAFit -> Int -> LA.Vector Double+forecastARIMA fit h =+ let _origY = arimaOrigSeries fit+ d = arimaD fit+ diff_d = iterate differencing _origY !! d+ arFc = forecastAR (arimaAR fit) diff_d h+ maFc = forecastMA (arimaMA fit) h+ combined = arFc + maFc - LA.scalar (arIntercept (arimaAR fit))+ -- Inverse-difference d times.+ lastObs = take d (reverse (LA.toList _origY))+ _ = lastObs+ in iterate (inverseDifferencing _origY) combined !! d++-- | First-difference: @y'_t = y_t - y_{t-1}@. Output length = n - 1.+differencing :: LA.Vector Double -> LA.Vector Double+differencing y =+ let n = LA.size y+ in if n < 2 then LA.fromList []+ else LA.subVector 1 (n - 1) y - LA.subVector 0 (n - 1) y++-- | Inverse first-difference given the last observation of the+-- original series. Output length = n + 1 (prepends the seed).+-- Simplified: cumulative sum prepended by 0.+inverseDifferencing+ :: LA.Vector Double -- ^ Original (for last value reference).+ -> LA.Vector Double -- ^ Differenced forecast.+ -> LA.Vector Double+inverseDifferencing origY diff =+ let lastY = LA.atIndex origY (LA.size origY - 1)+ cumS = scanl (+) lastY (LA.toList diff)+ in LA.fromList (drop 1 cumS)++-- ---------------------------------------------------------------------------+-- Exponential smoothing+-- ---------------------------------------------------------------------------++-- | Simple exponential smoothing (single, no trend / seasonality).+-- @s_t = α y_t + (1 − α) s_{t−1}@. Returns the smoothed series.+simpleExpSmoothing+ :: Double -- ^ α ∈ (0, 1).+ -> LA.Vector Double+ -> LA.Vector Double+simpleExpSmoothing alpha y =+ let ys = LA.toList y+ go _ [] = []+ go prev (yi:rest) =+ let sNew = alpha * yi + (1 - alpha) * prev+ in sNew : go sNew rest+ s0 = case ys of { (y0:_) -> y0; [] -> 0 }+ in LA.fromList (go s0 ys)++-- | Holt-Winters mode (additive vs multiplicative seasonality).+data HWMode = HWAdditive | HWMultiplicative deriving (Show, Eq)++-- | Fitted Holt-Winters (triple exponential smoothing).+data HWFit = HWFit+ { hwMode :: !HWMode+ , hwPeriod :: !Int+ , hwAlpha :: !Double+ , hwBeta :: !Double+ , hwGamma :: !Double+ , hwLevel :: !Double -- ^ Final level component.+ , hwTrend :: !Double -- ^ Final trend component.+ , hwSeasonal :: ![Double] -- ^ Final seasonal indices (length period).+ , hwFitted :: !(LA.Vector Double)+ } deriving (Show)++-- | Fit Holt-Winters (additive seasonal). Picks default smoothing+-- parameters @α = β = γ = 0.3@; for production use, optimise these.+holtWinters+ :: HWMode -- ^ Additive or multiplicative.+ -> Int -- ^ Seasonal period (e.g. 12 for monthly).+ -> LA.Vector Double -- ^ Time series.+ -> HWFit+holtWinters mode period y =+ let alpha = 0.3 :: Double+ beta = 0.1 :: Double+ gamma = 0.1 :: Double+ ys = LA.toList y+ -- Initialise from first 'period' observations.+ initLevel = sum (take period ys) / fromIntegral period+ initTrend = (sum (take period (drop period ys))+ - sum (take period ys))+ / fromIntegral (period * period)+ initSeas = case mode of+ HWAdditive ->+ [ ys !! i - initLevel | i <- [0 .. period - 1] ]+ HWMultiplicative ->+ [ ys !! i / max 1e-15 initLevel | i <- [0 .. period - 1] ]+ -- Iterate.+ go !lvl !trd !seas !fitted [] = (lvl, trd, seas, reverse fitted)+ go !lvl !trd !seas !fitted (yi:rest) =+ let p = period+ sIdx = length fitted `mod` p+ sCur = seas !! sIdx+ (lvlNew, trdNew, sNew, fHat) = case mode of+ HWAdditive ->+ let l' = alpha * (yi - sCur) + (1 - alpha) * (lvl + trd)+ t' = beta * (l' - lvl) + (1 - beta) * trd+ s' = gamma * (yi - l') + (1 - gamma) * sCur+ fh = lvl + trd + sCur+ in (l', t', s', fh)+ HWMultiplicative ->+ let l' = alpha * (yi / max 1e-15 sCur) + (1 - alpha) * (lvl + trd)+ t' = beta * (l' - lvl) + (1 - beta) * trd+ s' = gamma * (yi / max 1e-15 l') + (1 - gamma) * sCur+ fh = (lvl + trd) * sCur+ in (l', t', s', fh)+ seas' = updateAt sIdx sNew seas+ in go lvlNew trdNew seas' (fHat : fitted) rest+ (finalLvl, finalTrd, finalSeas, fits) =+ go initLevel initTrend initSeas [] ys+ in HWFit+ { hwMode = mode+ , hwPeriod = period+ , hwAlpha = alpha+ , hwBeta = beta+ , hwGamma = gamma+ , hwLevel = finalLvl+ , hwTrend = finalTrd+ , hwSeasonal = finalSeas+ , hwFitted = LA.fromList fits+ }++-- | Forecast @h@ steps ahead from a fitted Holt-Winters model.+hwForecast :: HWFit -> Int -> LA.Vector Double+hwForecast fit h =+ let lvl = hwLevel fit+ trd = hwTrend fit+ seas = hwSeasonal fit+ p = hwPeriod fit+ mode = hwMode fit+ go k+ | k > h = []+ | otherwise =+ let sIdx = (k - 1) `mod` p+ fc = case mode of+ HWAdditive -> lvl + fromIntegral k * trd + seas !! sIdx+ HWMultiplicative -> (lvl + fromIntegral k * trd) * seas !! sIdx+ in fc : go (k + 1)+ in LA.fromList (go 1)++-- ---------------------------------------------------------------------------+-- Helpers+-- ---------------------------------------------------------------------------++-- | Centred moving average with window @w@ (odd recommended). Values+-- near the edges have NaN.+movingAverage :: Int -> LA.Vector Double -> LA.Vector Double+movingAverage w y =+ let n = LA.size y+ half = w `div` 2+ avg i+ | i - half < 0 || i + half >= n = 0/0+ | otherwise = sum [LA.atIndex y (i + j) | j <- [-half .. half]]+ / fromIntegral w+ in LA.fromList [avg i | i <- [0 .. n - 1]]++-- | Simplified STL decomposition (loess-free version): subtract a+-- centred moving-average trend, then estimate seasonality as the+-- mean per phase.+stlDecompose+ :: Int -- ^ Period.+ -> LA.Vector Double+ -> (LA.Vector Double, LA.Vector Double, LA.Vector Double)+ -- ^ (trend, seasonal, residual).+stlDecompose period y =+ let n = LA.size y+ trend = movingAverage period y+ detrended = LA.fromList+ [ if isNaN (LA.atIndex trend i) then 0+ else LA.atIndex y i - LA.atIndex trend i+ | i <- [0 .. n - 1] ]+ -- Per-phase mean over non-NaN cells.+ phaseMeans =+ [ let maxJ = (n - 1 - i) `div` period+ xs = [LA.atIndex detrended (i + j * period)+ | j <- [0 .. maxJ], i + j * period < n]+ valid = filter (not . isNaN) xs+ in if null valid then 0 else sum valid / fromIntegral (length valid)+ | i <- [0 .. period - 1] ]+ -- Centre seasonal indices around 0.+ seasMean = sum phaseMeans / fromIntegral period+ seasonal = LA.fromList+ [ phaseMeans !! (i `mod` period) - seasMean | i <- [0 .. n - 1] ]+ residual = y - trend - seasonal+ in (trend, seasonal, residual)++-- | Update list element at index.+updateAt :: Int -> a -> [a] -> [a]+updateAt _ _ [] = []+updateAt 0 v (_:xs) = v : xs+updateAt i v (x:xs) = x : updateAt (i - 1) v xs+
+ src/Hanalyze/Model/VAR.hs view
@@ -0,0 +1,134 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE BangPatterns #-}++-- |+-- Module : Hanalyze.Model.VAR+-- Description : 多変量自己回帰 VAR(p) モデルの方程式別 OLS 推定と予測+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- VAR(p) — Vector AutoRegressive model.+--+-- Multivariate generalization of AR(p): for a @K@-dimensional series+-- @yₜ ∈ ℝᴷ@,+--+-- @+-- yₜ = c + A₁·yₜ₋₁ + A₂·yₜ₋₂ + … + Aₚ·yₜ₋ₚ + εₜ+-- @+--+-- where each @Aₗ@ is a @K × K@ coefficient matrix and @c@ is a length-@K@+-- intercept. Estimation is by equation-by-equation OLS, which is the+-- maximum-likelihood estimator for VAR under Gaussian innovations (the+-- stacked system has the same regressors in every equation, so SUR+-- collapses to OLS — Lütkepohl 2005 §3.2).+--+-- @+-- import Hanalyze.Model.VAR+--+-- let fit = fitVAR 2 yMat -- VAR(2) on n × K series+-- fc = forecastVAR fit yMat 10 -- 10-step ahead+-- @+--+-- == Implemented+--+-- * 'fitVAR' (equation-by-equation OLS, joint estimation)+-- * 'forecastVAR' (deterministic point forecast, h steps)+module Hanalyze.Model.VAR+ ( VARFit (..)+ , fitVAR+ , forecastVAR+ ) where++import qualified Numeric.LinearAlgebra as LA++-- ---------------------------------------------------------------------------+-- Types+-- ---------------------------------------------------------------------------++-- | Fitted VAR(p) model.+data VARFit = VARFit+ { varP :: !Int -- ^ Lag order @p@.+ , varK :: !Int -- ^ Series dimensionality @K@.+ , varConst :: !(LA.Vector Double) -- ^ Intercept @c@ (length @K@).+ , varCoefs :: ![LA.Matrix Double] -- ^ @[A₁, …, Aₚ]@, each @K × K@.+ , varResiduals :: !(LA.Matrix Double) -- ^ Residuals, @(n − p) × K@.+ , varSigma :: !(LA.Matrix Double) -- ^ Residual covariance @K × K@.+ } deriving (Show)++-- ---------------------------------------------------------------------------+-- Fitting+-- ---------------------------------------------------------------------------++-- | Fit a VAR(@p@) model to an @n × K@ series @Y@ by equation-by-equation+-- OLS. The first @p@ rows are consumed as the initial lag window;+-- @n − p@ effective observations are used. Requires @n > p · K + 1@.+fitVAR :: Int -> LA.Matrix Double -> VARFit+fitVAR p y =+ let n = LA.rows y+ k = LA.cols y+ neff = n - p+ -- Design matrix Z: each row t = [1, y_{t-1}, y_{t-2}, …, y_{t-p}]+ -- (1 + p·K columns), for t = p, p+1, …, n-1.+ buildRow t =+ 1.0 : concat [ LA.toList (LA.flatten (y LA.? [t - l]))+ | l <- [1 .. p] ]+ zRows = [ buildRow t | t <- [p .. n - 1] ]+ z = LA.fromLists zRows -- (neff × (1 + p·K))+ yLag = y LA.?? (LA.Drop p, LA.All) -- (neff × K)+ -- OLS: B = (Zᵀ Z)⁻¹ Zᵀ Y. Use linearSolveLS (least squares) for+ -- numerical stability.+ bMat = LA.linearSolveLS z yLag -- ((1 + p·K) × K)+ cVec = LA.flatten (bMat LA.? [0]) -- intercept (K,)+ coefs =+ [ LA.tr (bMat LA.?? ( LA.Pos (LA.idxs [ 1 + (l - 1) * k + j+ | j <- [0 .. k - 1] ])+ , LA.All ))+ | l <- [1 .. p] ]+ -- Each block row of B is K rows giving Aₗᵀ; transpose for K × K Aₗ.+ yhat = z LA.<> bMat+ resid = yLag - yhat+ sigma = (LA.tr resid LA.<> resid)+ / fromIntegral (max 1 (neff - (1 + p * k)))+ in VARFit+ { varP = p+ , varK = k+ , varConst = cVec+ , varCoefs = coefs+ , varResiduals = resid+ , varSigma = sigma+ }++-- ---------------------------------------------------------------------------+-- Forecasting+-- ---------------------------------------------------------------------------++-- | Deterministic @h@-step-ahead point forecast (ε set to zero):+--+-- @+-- ŷ_{T+k} = c + Σₗ Aₗ · ŷ_{T+k-ℓ}+-- @+--+-- where @ŷ_{T+j} = y_{T+j}@ for @j ≤ 0@. The full input series @y@ is+-- accepted to supply the last @p@ rows used as initial history.+forecastVAR :: VARFit -> LA.Matrix Double -> Int -> LA.Matrix Double+forecastVAR fit y h+ | h <= 0 = LA.fromLists []+ | otherwise =+ let p = varP fit+ n = LA.rows y+ -- Initial history: last p rows of y, as a [Vector Double] list+ -- with index 0 = y_{T-1}, index 1 = y_{T-2}, …, index p-1 = y_{T-p}.+ hist0 = [ LA.flatten (y LA.? [n - 1 - i]) | i <- [0 .. p - 1] ]+ step !hist =+ let !pred_ =+ varConst fit+ + foldr1 (+)+ [ (varCoefs fit !! (l - 1)) LA.#> (hist !! (l - 1))+ | l <- [1 .. p] ]+ in (pred_, pred_ : init hist)+ go !k !hist acc+ | k > h = reverse acc+ | otherwise =+ let (yk, hist') = step hist+ in go (k + 1) hist' (yk : acc)+ in LA.fromRows (go 1 hist0 [])
+ src/Hanalyze/Model/Weibull.hs view
@@ -0,0 +1,394 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE BangPatterns #-}++-- |+-- Module : Hanalyze.Model.Weibull+-- Description : Weibull 分布の最尤推定・B_x 寿命・Wald 標準誤差 (信頼性/故障時間解析の中核)+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- [日本語]: Weibull 分布の最尤推定 + B_x 寿命 + Wald SE。+--+-- 信頼性 / 故障時間解析の中核。 半導体 / 材料分野の加速試験データ解析に使う。+-- 加速モデル (Arrhenius / Eyring / Inverse Power Law) は+-- @Hanalyze.Model.Reliability@ で別途扱う。+--+-- Weibull(k, λ) の確率密度 / 生存関数:+--+-- > f(x) = (k/λ) (x/λ)^(k-1) exp(-(x/λ)^k) for x > 0+-- > S(x) = exp(-(x/λ)^k)+--+-- 形状 k と尺度 λ は両方とも正。 k < 1 は故障率低下 (初期不良)、 k = 1 は+-- 指数分布、 k > 1 は故障率上昇 (摩耗故障)。+--+-- [English]: Weibull distribution maximum-likelihood estimation + B_x life ++-- Wald SE.+--+-- The core of reliability \/ time-to-failure analysis. Used for analyzing+-- accelerated test data in the semiconductor \/ materials domains.+-- Acceleration models (Arrhenius \/ Eyring \/ Inverse Power Law) are handled+-- separately in @Hanalyze.Model.Reliability@.+--+-- The probability density \/ survival function of Weibull(k, λ):+--+-- > f(x) = (k/λ) (x/λ)^(k-1) exp(-(x/λ)^k) for x > 0+-- > S(x) = exp(-(x/λ)^k)+--+-- Both the shape k and scale λ are positive. k < 1 means decreasing failure+-- rate (early failures/infant mortality), k = 1 is the exponential+-- distribution, and k > 1 means increasing failure rate (wear-out failures).+module Hanalyze.Model.Weibull+ ( -- * 結果型+ WeibullFit (..)+ -- * MLE fit+ , fitWeibullMLE+ , fitWeibullCensored+ -- * 派生量+ , bxLife+ , bxLifeCI+ , weibullParameterSE+ , weibullParameterCovariance+ -- * 数値ユーティリティ+ , quantileNormal+ ) where++import Data.Text (Text)+import Data.Vector (Vector)+import qualified Data.Vector as V++-- ===========================================================================+-- 型定義+-- ===========================================================================++-- | [日本語]: Weibull MLE 結果。+-- [English]: A Weibull MLE result.+data WeibullFit = WeibullFit+ { wfShape :: !Double -- ^ [日本語]: k (形状パラメータ、 > 0) [English]: k (the shape parameter, > 0)+ , wfScale :: !Double -- ^ [日本語]: λ (尺度パラメータ、 > 0) [English]: λ (the scale parameter, > 0)+ , wfLogLik :: !Double -- ^ [日本語]: 対数尤度の MLE 値 [English]: The log-likelihood at the MLE+ , wfN :: !Int -- ^ [日本語]: 観測総数 (打ち切り含む) [English]: The total number of observations (including censored)+ , wfRObs :: !Int -- ^ [日本語]: 観測 failure 数 (打ち切り除く) [English]: The number of observed failures (excluding censored)+ , wfFisher :: !(Double, Double, Double)+ -- ^ [日本語]: Fisher 情報行列 2x2 を上三角 (I_kk, I_kλ, I_λλ) で保持。+ -- Wald SE 計算で逆行列を取る。+ -- [English]: Holds the upper triangle of the 2x2 Fisher information+ -- matrix (I_kk, I_kλ, I_λλ). Its inverse is taken for the Wald SE+ -- calculation.+ } deriving (Show)++-- ===========================================================================+-- 内部ヘルパ+-- ===========================================================================++-- | [日本語]: 観測値リストの sanity check (全て正で非空)。+-- [English]: A sanity check on the observation list (all positive and+-- non-empty).+validatePositive :: Vector Double -> Either Text ()+validatePositive xs+ | V.null xs = Left "fitWeibull: empty observation series"+ | V.any (<= 0) xs = Left "fitWeibull: all observations must be positive"+ | otherwise = Right ()++-- | [日本語]: A(k) = Σ x_i^k log x_i (failures のみ加算する版は censored 用)。+-- [English]: A(k) = Σ x_i^k log x_i (the variant summing only failures is+-- for the censored case).+weightedLog :: Double -> Vector Double -> Double+weightedLog k xs = V.sum (V.map (\x -> x ** k * log x) xs)++-- | [日本語]: B(k) = Σ x_i^k。 censored 含む場合は加算範囲を呼び出し側で制御する。+-- [English]: B(k) = Σ x_i^k. When censored observations are included, the+-- caller controls the summation range.+sumPow :: Double -> Vector Double -> Double+sumPow k xs = V.sum (V.map (** k) xs)++-- | [日本語]: g(k) = A(k)/B(k) − (1/r)·Σ_{failures} log x − 1/k = 0+-- r = failure 数。 単調増加なので bisection で root を取れる。+-- [English]: g(k) = A(k)/B(k) − (1/r)·Σ_{failures} log x − 1/k = 0,+-- where r = the number of failures. Since it is monotonically increasing,+-- the root can be found via bisection.+scoreG :: Double -> Vector Double -> Vector Double -> Int -> Double+scoreG k allXs failuresXs r =+ let bk = sumPow k allXs+ ak = weightedLog k allXs+ meanLogFail = V.sum (V.map log failuresXs) / fromIntegral r+ in ak / bk - meanLogFail - 1 / k++-- | [日本語]: 単調増加関数の root を bisection で。 区間 [lo, hi] で g(lo) < 0 < g(hi) を仮定。+-- [English]: Finds the root of a monotonically increasing function via+-- bisection. Assumes g(lo) < 0 < g(hi) over the interval [lo, hi].+bisect+ :: (Double -> Double) -- 単調増加 g+ -> Double -- lo+ -> Double -- hi+ -> Double -- 許容誤差+ -> Int -- 最大反復+ -> Either Text Double+bisect g lo0 hi0 tol maxIter = go lo0 hi0 0+ where+ go !lo !hi !i+ | i >= maxIter = Left "Weibull MLE: bisection did not converge"+ | (hi - lo) < tol = Right ((lo + hi) / 2)+ | otherwise =+ let mid = (lo + hi) / 2+ gm = g mid+ in if gm > 0+ then go lo mid (i + 1)+ else go mid hi (i + 1)++-- | [日本語]: 区間を「拡張 + 縮小」 でブラケットを取る。+-- 関数 g は単調増加。 g(start_lo) ≥ 0 や g(start_hi) ≤ 0 の場合は範囲を広げる。+-- [English]: Brackets the root by "expanding + shrinking" the interval.+-- g is monotonically increasing; the range is widened when g(start_lo) ≥ 0+-- or g(start_hi) ≤ 0.+findBracket+ :: (Double -> Double)+ -> Double -- 初期 lo (>0)+ -> Double -- 初期 hi+ -> Int -- 最大拡張回数+ -> Either Text (Double, Double)+findBracket g lo0 hi0 maxExp = go lo0 hi0 0+ where+ go !lo !hi !i+ | i >= maxExp = Left "Weibull MLE: failed to bracket root"+ | otherwise =+ let glo = g lo+ ghi = g hi+ in if glo <= 0 && ghi >= 0+ then Right (lo, hi)+ else if glo > 0 -- root より大きすぎる+ then go (lo / 4) hi (i + 1)+ else if ghi < 0 -- root より小さすぎる+ then go lo (hi * 4) (i + 1)+ else Right (lo, hi)++-- | [日本語]: 全観測 failure 仮定で MLE を解く中核ロジック。+-- xs (failure 時間) + xsAll (全観測; censored 含む) を分けるのは、 打ち切りに+-- 対応するため。+-- [English]: The core logic solving the MLE, assuming all observations are+-- failures. Splitting xs (failure times) from xsAll (all observations,+-- including censored) is to support censoring.+solveWeibull+ :: Vector Double -- failures (時間)+ -> Vector Double -- 全観測 (失敗 + 打ち切り)+ -> Int -- failure 数 r+ -> Either Text WeibullFit+solveWeibull failuresXs allXs r = do+ let g k = scoreG k allXs failuresXs r+ (lo, hi) <- findBracket g 0.1 10.0 30+ k <- bisect g lo hi 1e-10 200+ let bk = sumPow k allXs+ lam = (bk / fromIntegral r) ** (1 / k)+ -- log-likelihood at MLE (failures contribution + censored survival)+ n = V.length allXs+ sumLogFailures = V.sum (V.map log failuresXs)+ sumScaled = V.sum (V.map (\x -> (x / lam) ** k) allXs)+ ll = fromIntegral r * (log k - k * log lam)+ + (k - 1) * sumLogFailures+ - sumScaled+ -- 観測 Fisher 情報 (uncensored 公式; censored ではバイアスあり)+ -- I_kk ≈ r / k^2 + Σ (x/λ)^k (log(x/λ))^2+ -- I_λλ ≈ k^2 · (Σ (x/λ)^k) / λ^2 − r k / λ^2 ... 簡素化:+ -- 厳密 expected information を Phase 2.4 で詰める。 ここでは+ -- observed information (負 Hessian) の対角成分を返す。+ iKK = fromIntegral r / (k * k)+ + V.sum (V.map (\x -> (x / lam) ** k * (log (x / lam))**2) allXs)+ iLL = (k * k / (lam * lam)) * V.sum (V.map (\x -> (x / lam) ** k) allXs)+ - fromIntegral r * k / (lam * lam) + 2 * k * fromIntegral r / (lam * lam)+ -- 教科書: I_λλ = r·k² / λ² (uncensored at MLE は Σ (x/λ)^k = r)+ -- censored の場合は上の Σ がそのまま入る。+ iKL = V.sum (V.map (\x -> (x / lam) ** k * log (x / lam)) allXs)+ * (k / lam)+ - fromIntegral r / lam+ pure WeibullFit+ { wfShape = k+ , wfScale = lam+ , wfLogLik = ll+ , wfN = n+ , wfRObs = r+ , wfFisher = (iKK, iKL, iLL)+ }++-- ===========================================================================+-- 公開関数+-- ===========================================================================++-- | [日本語]: Weibull MLE (打ち切り無し)。+--+-- 入力: 全て観測済の故障時間 (> 0)。+-- 解法: score equation @1/k = A(k)/B(k) − (1/n)·Σ log x@ を 1D bisection で+-- 解き、 λ = (Σ x^k / n)^(1/k)。+--+-- [English]: Weibull MLE (no censoring).+--+-- Input: fully observed failure times (> 0).+-- Method: solves the score equation @1/k = A(k)/B(k) − (1/n)·Σ log x@ via+-- 1-D bisection, then λ = (Σ x^k / n)^(1/k).+fitWeibullMLE :: Vector Double -> Either Text WeibullFit+fitWeibullMLE xs = do+ _ <- validatePositive xs+ if V.length xs < 2+ then Left "fitWeibullMLE: need at least 2 observations"+ else+ let logs = V.map log xs+ maxL = V.maximum logs+ meanL = V.sum logs / fromIntegral (V.length xs)+ in if abs (maxL - meanL) < 1e-12+ then Left "fitWeibullMLE: data is constant (degenerate)"+ else solveWeibull xs xs (V.length xs)++-- | [日本語]: Weibull MLE (右打ち切り対応)。+--+-- 第 2 引数の @True@ = failure observed、 @False@ = right-censored。+-- 同じ score equation @1/k = A_all(k)/B_all(k) − (1/r)·Σ_{δ=1} log x@ を解くが、+-- @A@, @B@ は 全観測 (failure + 打ち切り) で加算し、 log-sum は failure のみ。+-- @r@ は failure 数。+--+-- [English]: Weibull MLE (supports right censoring).+--+-- The second argument's @True@ = failure observed, @False@ = right-censored.+-- Solves the same score equation+-- @1/k = A_all(k)/B_all(k) − (1/r)·Σ_{δ=1} log x@, but @A@ and @B@ sum over+-- all observations (failures + censored), while the log-sum is over failures+-- only. @r@ is the number of failures.+fitWeibullCensored :: Vector Double -> Vector Bool -> Either Text WeibullFit+fitWeibullCensored xs deltas = do+ _ <- validatePositive xs+ if V.length xs /= V.length deltas+ then Left "fitWeibullCensored: times and delta indicators differ in length"+ else+ let failuresXs = V.ifilter (\i _ -> deltas V.! i) xs+ r = V.length failuresXs+ in if r < 2+ then Left "fitWeibullCensored: need at least 2 observed failures"+ else+ let logsFail = V.map log failuresXs+ maxL = V.maximum logsFail+ meanL = V.sum logsFail / fromIntegral r+ in if abs (maxL - meanL) < 1e-12+ then Left "fitWeibullCensored: failure data is constant (degenerate)"+ else solveWeibull failuresXs xs r++-- | [日本語]: B_p 寿命: F^{-1}(p) = λ · (−ln(1−p))^(1/k)。+--+-- 典型用途: @bxLife 0.10 fit@ → B_10 (10%故障時間)、+-- @bxLife 0.50 fit@ → B_50 (中央寿命)。+--+-- [English]: B_p life: F^{-1}(p) = λ · (−ln(1−p))^(1/k).+--+-- Typical usage: @bxLife 0.10 fit@ → B_10 (the 10% failure time),+-- @bxLife 0.50 fit@ → B_50 (the median life).+bxLife :: Double -> WeibullFit -> Double+bxLife p _ | p <= 0 || p >= 1 = error "bxLife: probability must be in (0, 1)"+bxLife p fit =+ let k = wfShape fit+ lam = wfScale fit+ in lam * (- log (1 - p)) ** (1 / k)++-- | [日本語]: (k_SE, λ_SE) — Fisher 情報行列の逆行列の対角の平方根。+--+-- 2x2 逆行列: var(k) = I_λλ / det、 var(λ) = I_kk / det、 det = I_kk·I_λλ − I_kλ²+--+-- [English]: (k_SE, λ_SE) — the square root of the diagonal of the inverse+-- Fisher information matrix.+--+-- 2x2 inverse: var(k) = I_λλ / det, var(λ) = I_kk / det,+-- det = I_kk·I_λλ − I_kλ².+weibullParameterSE :: WeibullFit -> (Double, Double)+weibullParameterSE fit =+ let (vK, _, vL) = weibullParameterCovariance fit+ in (sqrt (max 0 vK), sqrt (max 0 vL))++-- | [日本語]: (Var(k), Cov(k, λ), Var(λ))。 Fisher 情報行列の 2x2 逆行列。+-- 非正定値の場合は (0, 0, 0) を返す (canvas 側で警告するための signal)。+-- [English]: (Var(k), Cov(k, λ), Var(λ)). The 2x2 inverse of the Fisher+-- information matrix. Returns (0, 0, 0) when not positive-definite (a+-- signal for the canvas side to warn about).+weibullParameterCovariance :: WeibullFit -> (Double, Double, Double)+weibullParameterCovariance fit =+ let (iKK, iKL, iLL) = wfFisher fit+ det = iKK * iLL - iKL * iKL+ in if det <= 0+ then (0, 0, 0)+ else (iLL / det, -iKL / det, iKK / det)++-- | [日本語]: B_p 寿命の Wald 信頼区間 (delta method)。+--+-- @bxLifeCI p α fit@ で「故障時間が確率 p に達する時刻」 の+-- 信頼度 @1 − α@ 信頼区間 (例: α = 0.05 で 95% CI) を返す。+--+-- delta method:+--+-- > Var(B_p) ≈ (∂B_p/∂k)² Var(k) + (∂B_p/∂λ)² Var(λ) + 2 (∂B_p/∂k)(∂B_p/∂λ) Cov(k,λ)+-- > ∂B_p/∂λ = B_p / λ+-- > ∂B_p/∂k = −B_p · log(−log(1−p)) / k²+--+-- 戻り値: @(estimate, lower, upper)@。 lower は max(0, ...) で 0 にクリップ+-- (寿命は非負)。 共分散が非正定値で SE 計算不能の場合は @(estimate, estimate, estimate)@。+--+-- 注: α は両側で考えるので 95% CI なら z = 1.96 を内部使用。+--+-- [English]: The Wald confidence interval for B_p life (delta method).+--+-- @bxLifeCI p α fit@ returns the @1 − α@ confidence interval (e.g. α = 0.05+-- for a 95% CI) for "the time at which the failure probability reaches p".+--+-- Delta method:+--+-- > Var(B_p) ≈ (∂B_p/∂k)² Var(k) + (∂B_p/∂λ)² Var(λ) + 2 (∂B_p/∂k)(∂B_p/∂λ) Cov(k,λ)+-- > ∂B_p/∂λ = B_p / λ+-- > ∂B_p/∂k = −B_p · log(−log(1−p)) / k²+--+-- Return value: @(estimate, lower, upper)@. lower is clipped to 0 via+-- max(0, ...) (life is non-negative). When the covariance is not+-- positive-definite and the SE cannot be computed, returns+-- @(estimate, estimate, estimate)@.+--+-- Note: since α is considered two-sided, a 95% CI uses z = 1.96 internally.+bxLifeCI :: Double -> Double -> WeibullFit -> (Double, Double, Double)+bxLifeCI p alpha fit =+ let bp = bxLife p fit+ k = wfShape fit+ lam = wfScale fit+ (vK, cKL, vL) = weibullParameterCovariance fit+ logArg = log (- log (1 - p))+ dbdL = bp / lam+ dbdK = - bp * logArg / (k * k)+ varBp = dbdK * dbdK * vK + dbdL * dbdL * vL + 2 * dbdK * dbdL * cKL+ seBp = if varBp > 0 then sqrt varBp else 0+ z = quantileNormal (1 - alpha / 2)+ lo = max 0 (bp - z * seBp)+ hi = bp + z * seBp+ in (bp, lo, hi)++-- | [日本語]: 標準正規分布の分位点 (近似)。 95% CI で z = 1.959964…。+-- Acklam 高精度近似 (12 桁) を採用。+-- [English]: The quantile of the standard normal distribution+-- (approximate). z = 1.959964… for a 95% CI. Uses Acklam's+-- high-precision approximation (12 digits).+quantileNormal :: Double -> Double+quantileNormal q+ | q <= 0 || q >= 1 = error "quantileNormal: q must be in (0, 1)"+ | q < pLow = let qn = sqrt (-2 * log q) in+ (((((cN1 * qn + cN2) * qn + cN3) * qn + cN4) * qn + cN5) * qn + cN6)+ / ((((dN1 * qn + dN2) * qn + dN3) * qn + dN4) * qn + 1)+ | q <= pHigh = let qn = q - 0.5; r = qn * qn in+ ((((((aN1 * r + aN2) * r + aN3) * r + aN4) * r + aN5) * r + aN6) * qn)+ / (((((bN1 * r + bN2) * r + bN3) * r + bN4) * r + bN5) * r + 1)+ | otherwise = let qn = sqrt (-2 * log (1 - q)) in+ negate $+ (((((cN1 * qn + cN2) * qn + cN3) * qn + cN4) * qn + cN5) * qn + cN6)+ / ((((dN1 * qn + dN2) * qn + dN3) * qn + dN4) * qn + 1)+ where+ pLow = 0.02425+ pHigh = 1 - pLow+ aN1 = -3.969683028665376e1; aN2 = 2.209460984245205e2+ aN3 = -2.759285104469687e2; aN4 = 1.383577518672690e2+ aN5 = -3.066479806614716e1; aN6 = 2.506628277459239e0+ bN1 = -5.447609879822406e1; bN2 = 1.615858368580409e2+ bN3 = -1.556989798598866e2; bN4 = 6.680131188771972e1+ bN5 = -1.328068155288572e1+ cN1 = -7.784894002430293e-3; cN2 = -3.223964580411365e-1+ cN3 = -2.400758277161838e0; cN4 = -2.549732539343734e0+ cN5 = 4.374664141464968e0; cN6 = 2.938163982698783e0+ dN1 = 7.784695709041462e-3; dN2 = 3.224671290700398e-1+ dN3 = 2.445134137142996e0; dN4 = 3.754408661907416e0
+ src/Hanalyze/Optim/BayesOpt.hs view
@@ -0,0 +1,800 @@+-- |+-- Module : Hanalyze.Optim.BayesOpt+-- Description : ベイズ最適化ループ (GP フィット + 獲得関数最大化)+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- Bayesian Optimization loop.+--+-- Single-objective procedure:+--+-- 1. Evaluate initial points (Latin hypercube or random).+-- 2. Fit a Gaussian process to the observations.+-- 3. Maximize an acquisition function to choose the next @x@.+-- 4. Evaluate @x@ and append to the observed sequence.+-- 5. Repeat steps 2-4 for @T@ iterations.+{-# LANGUAGE StrictData #-}+{-# LANGUAGE OverloadedStrings #-}+module Hanalyze.Optim.BayesOpt+ ( BayesOptConfig (..)+ , defaultBayesOptConfig+ , BOIterEvent (..)+ , bayesOpt+ , bayesOptWithCallback+ , bayesOptND+ , bayesOptScalarMO+ , bayesOptMOWithNSGA+ -- * GP HP optimization helpers+ , optimizeGPMVRestart+ , optimizeHPMultiRestart+ ) where++import Control.Exception (SomeException, try, evaluate)+import Control.Monad (forM, replicateM)+import Data.List (minimumBy, maximumBy, sortBy)+import Data.Ord (comparing)+import System.IO.Unsafe (unsafePerformIO)+import System.Random.MWC (GenIO, uniform)++import Hanalyze.Model.GP (Kernel (..), GPModel (..), GPResult (..), GPParams (..),+ gpKernelParams,+ fitGP, optimizeGP, initParamsFromData,+ GPResultMV (..), fitGPMV, optimizeGPMV,+ logMarginalLikelihoodMV,+ buildKernelMatrixMV, noiseKernelMV)+import qualified Hanalyze.Stat.Cholesky as Chol+import qualified Hanalyze.Stat.KernelDist as KD+import Hanalyze.Optim.Acquisition (ei, ucb, pi_, parEGO)+import Hanalyze.Optim.NSGA (NSGAConfig (..), defaultNSGAConfig,+ Solution (..), nsga2)+import Hanalyze.Optim.Common (Bounds)+import qualified Hanalyze.Optim.LineSearch as LS+import qualified Hanalyze.Optim.LBFGS as LBFGS+import qualified Hanalyze.Optim.Common as OC+import qualified Numeric.LinearAlgebra as LA+import qualified Hanalyze.Stat.QuasiRandom as QR+import qualified Hanalyze.Stat.Standardize as Std+import Statistics.Distribution (cumulative, density)+import Statistics.Distribution.Normal (standard)++-- | Bayesian Optimization configuration.+data BayesOptConfig = BayesOptConfig+ { boIterations :: Int -- ^ Evaluation budget (excluding initial points).+ , boInitPoints :: Int -- ^ Number of initial sample points.+ , boKernel :: Kernel -- ^ GP kernel.+ , boUCBBeta :: Double -- ^ @β@ for UCB.+ , boGridSize :: Int -- ^ Inner-optimization grid density (1D).+ } deriving (Show)++-- | Default configuration: 30 iterations, 5 initial points,+-- __Matérn 5/2 kernel__, @β = 2.0@ for UCB, grid size 200 for 1D+-- inner optimization.+--+-- Matérn 5/2 is the recommended default for general-purpose BO+-- (matches scikit-optimize's defaults). RBF is too smooth for many+-- real-world objective surfaces; Matérn captures the @C²@ regularity+-- typical of engineering / black-box functions and is what the BO+-- literature converged on.+defaultBayesOptConfig :: BayesOptConfig+defaultBayesOptConfig = BayesOptConfig+ { boIterations = 30+ , boInitPoints = 5+ , boKernel = Matern52+ , boUCBBeta = 2.0+ , boGridSize = 200+ }++-- | Single-objective Bayesian Optimization (1D simplified entry point).+--+-- Returns @(observations, best)@: the full @(x, y)@ history and the best+-- @(x*, y*)@.+-- | [日本語]: BO の各 iteration 末端で発火するイベント。+-- [English]: The event fired at the end of each BO iteration.+data BOIterEvent = BOIterEvent+ { boeIter :: !Int -- ^ [日本語]: 0-based iteration index。 [English]: 0-based iteration index.+ , boeProposedX :: !Double -- ^ [日本語]: acquisition が選んだ新点。 [English]: The new point chosen by the acquisition function.+ , boeProposedY :: !Double -- ^ [日本語]: そこでの f 値。 [English]: The f value at that point.+ , boeCurrentBest :: !(Double, Double) -- ^ [日本語]: (x*, y*) これまで。 [English]: The best (x*, y*) so far.+ } deriving (Show)++bayesOpt :: BayesOptConfig+ -> (Double -> IO Double) -- ^ Objective (1D, minimized).+ -> (Double, Double) -- ^ Search bounds.+ -> GenIO+ -> IO ([(Double, Double)], (Double, Double))+bayesOpt cfg f bounds gen =+ bayesOptWithCallback cfg f bounds gen (\_ -> pure ())++-- | [日本語]: BO iteration ごとに 'BOIterEvent' を渡す callback 付き版。+-- 既存 'bayesOpt' は no-op callback の wrapper として保持される。+-- [English]: A version with a callback that passes a 'BOIterEvent' on+-- every BO iteration. The existing 'bayesOpt' is kept as a wrapper around a+-- no-op callback.+bayesOptWithCallback+ :: BayesOptConfig+ -> (Double -> IO Double)+ -> (Double, Double)+ -> GenIO+ -> (BOIterEvent -> IO ())+ -> IO ([(Double, Double)], (Double, Double))+bayesOptWithCallback cfg f (lo, hi) gen onIter = do+ -- 初期点 (uniform random, 簡易)+ initX <- replicateM (boInitPoints cfg) (do+ u <- uniform gen :: IO Double+ return (lo + u * (hi - lo)))+ initY <- mapM f initX+ let history0 = zip initX initY+ totalIter = boIterations cfg++ -- BO ループ+ -- 内側 acquisition 最大化は **Brent 法** (1D 単峰超線形収束)。+ -- 旧 grid (boGridSize 点) は seeding として併用、Brent の bracket を作る。+ let loop t hist+ | t == 0 = return hist+ | otherwise = do+ let xs = map fst hist+ ys = map snd hist+ yBest = minimum ys+ p0 = initParamsFromData xs ys+ pOpt = optimizeGP (boKernel cfg) xs ys p0+ model = GPModel (boKernel cfg) pOpt++ -- 1 点での負 EI (Brent は最小化、引数は [Double] で受ける)+ -- Cholesky / SVD 失敗時はペナルティ +1e30 を返す。+ -- gpMean / gpUpper は遅延フィールドなので evaluate で強制してから返す。+ negEI [x] = unsafePerformIO $ do+ let computed = do+ let res = fitGP model xs ys [x]+ mu = head (gpMean res)+ sg = (head (gpUpper res) - mu) / 2+ _ <- evaluate mu+ _ <- evaluate sg+ pure (negate (ei yBest 0.01 (mu, sg)))+ r <- try computed :: IO (Either SomeException Double)+ case r of+ Left _ -> pure 1e30+ Right v -> pure v+ negEI _ = error "negEI: 1D"++ -- 粗グリッドで bracket を作る+ gridN = max 16 (boGridSize cfg `div` 4)+ grid = [lo + fromIntegral i * (hi - lo)+ / fromIntegral (gridN - 1)+ | i <- [0 .. gridN - 1]]+ gridV = [(x, negEI [x]) | x <- grid]+ bestG = minimumBy (comparing snd) gridV+ bestX = fst bestG+ idxBest = case [i | (i, (gx, _)) <- zip [0::Int ..] gridV, gx == bestX] of+ (k:_) -> k; [] -> 0+ ax = fst (gridV !! max 0 (idxBest - 1))+ bx = fst (gridV !! min (gridN - 1) (idxBest + 1))+ -- Brent で局所最大 (= 負の最小)+ bRes = LS.brent (LS.defaultBrentConfig { LS.bcMaxIter = 80+ , LS.bcTol = 1e-7 })+ negEI (min ax bx) (max ax bx)+ xNext = head (OC.orBest bRes)++ yNext <- f xNext+ let newHist = hist ++ [(xNext, yNext)]+ bestPair = head [pair | pair@(_, y) <- newHist+ , y == minimum (map snd newHist)]+ iterIdx = totalIter - t -- 0-based+ onIter BOIterEvent+ { boeIter = iterIdx+ , boeProposedX = xNext+ , boeProposedY = yNext+ , boeCurrentBest = bestPair+ }+ loop (t - 1) newHist++ finalHist <- loop totalIter history0+ let bestPair = head [pair | pair@(_, y) <- finalHist+ , y == minimum (map snd finalHist)]+ return (finalHist, bestPair)++-- ---------------------------------------------------------------------------+-- GP HP optimization with multiple random restarts+-- ---------------------------------------------------------------------------++-- | Optimize a GP's hyperparameters with multiple random restarts and+-- pick the best (highest marginal likelihood). One restart corresponds+-- to a single 'optimizeGPMV' call from a perturbed initial point.+--+-- Critical for BO performance: the marginal-likelihood surface is+-- multi-modal, so a single fixed init is not robust. scikit-optimize+-- defaults to @n_restarts_optimizer = 0@ (= 1 fit) but its kernel has+-- the prior baked in; for our wider search we use 5 restarts.+optimizeGPMVRestart+ :: Int -- ^ Number of restarts.+ -> Kernel+ -> LA.Matrix Double -- ^ Training X (n × p).+ -> LA.Vector Double -- ^ Training y (length n).+ -> GenIO+ -> IO GPParams+optimizeGPMVRestart n kern x y gen = do+ let p0base = initParamsFromData (concat (LA.toLists x)) (LA.toList y)+ -- generate n random initial points: log-spaced perturbation of p0base+ -- to cover several orders of magnitude.+ let scaleVar = sqrt . max 1e-6+ inits <- forM [1 .. n] $ \_ -> do+ u1 <- uniform gen :: IO Double+ u2 <- uniform gen :: IO Double+ u3 <- uniform gen :: IO Double+ -- log-uniform multipliers in [0.1, 10]+ let m1 = exp ((u1 - 0.5) * 2 * log 10)+ m2 = exp ((u2 - 0.5) * 2 * log 10)+ m3 = exp ((u3 - 0.5) * 2 * log 10)+ pure $ p0base+ { gpLengthScale = max 1e-3 (gpLengthScale p0base * m1)+ , gpSignalVar = max 1e-6 (scaleVar (gpSignalVar p0base) * m2)+ , gpNoiseVar = max 1e-6 (gpNoiseVar p0base * m3)+ }+ let runOne p0 = do+ let pOpt = optimizeGPMV kern x y p0+ ll = logMarginalLikelihoodMV x y kern pOpt+ pure (pOpt, ll)+ results <- mapM runOne inits+ let (best, _) = head [ r | r@(_, ll) <- results+ , ll == maximum (map snd results) ]+ pure best++-- | [日本語]: N-dimensional single-objective Bayesian Optimization。+-- 内側 acquisition 最大化を __L-BFGS multi-start__ で行う:+-- bounds 範囲内で nStarts 個の初期点を一様乱数で生成、各点から L-BFGS で+-- 負 EI を最小化、最良点を採用。+-- [English]: N-dimensional single-objective Bayesian Optimization.+-- The inner acquisition maximization is done via __L-BFGS multi-start__:+-- generate nStarts initial points via uniform random sampling within the+-- bounds, minimize the negative EI from each with L-BFGS, and take the best+-- point.+bayesOptND :: BayesOptConfig+ -> Int -- ^ [日本語]: multi-start 数 (典型 5-20)。 [English]: The number of multi-starts (typically 5-20).+ -> ([Double] -> IO Double) -- ^ [日本語]: 目的関数 (N 次元、最小化)。 [English]: The objective function (N-dimensional, minimized).+ -> Bounds -- ^ [日本語]: 各次元 (lo, hi)。 [English]: The (lo, hi) bounds for each dimension.+ -> GenIO+ -> IO ([([Double], Double)], ([Double], Double))+bayesOptND cfg nStarts f bounds gen = do+ let dim = length bounds+ kern = boKernel cfg+ -- Initial design: low-discrepancy Halton sequence (better+ -- coverage of the box than iid uniform random for the small @n@+ -- typical of BO initial designs).+ initX = QR.haltonSequenceIn (boInitPoints cfg) bounds+ sampleX = forM bounds $ \(lo, hi) -> do+ u <- uniform gen :: IO Double+ return (lo + u * (hi - lo))+ initY <- mapM f initX+ let history0 = zip initX initY++ -- BO2: per-dim X scaling — map every dim to [0, 1] using its (lo, hi)+ -- bound. After this, a single isotropic ℓ in the GP equates to per-dim+ -- length scales = ℓ × (hi - lo) in the original space, i.e. ARD with+ -- weights tied to the box width. skopt's "transform=normalize"+ -- preprocessing achieves the same effect.+ let scaleX :: [Double] -> [Double]+ scaleX xs = [ if hi > lo then (v - lo) / (hi - lo) else v+ | ((lo, hi), v) <- zip bounds xs ]+ unitBounds = replicate dim (0, 1)+ -- Phase B (GP-Hedge, Hoffman 2011): maintain online "gains" for+ -- {EI, LCB, PI}. Each iteration each acquisition proposes its best+ -- candidate via L-BFGS multi-start; one is selected by softmax over+ -- gains, evaluated, and gains are updated using the GP's predicted+ -- μ at every proposal (lower μ = higher reward for minimisation).+ -- This protects against any single acquisition's pathological+ -- behaviour on a given problem (e.g. EI's exploitation bias on+ -- multi-modal Branin).+ let hedgeEta = 1.0 :: Double+ pickAcq gains gen0 = do+ let m = maximum gains+ ws = map (\g -> exp (hedgeEta * (g - m))) gains+ tot = sum ws+ ps = map (/ tot) ws+ u <- uniform gen0 :: IO Double+ let cum = scanl1 (+) ps+ pure (length (takeWhile (< u) cum))+ let loop t hist gains+ | t == 0 = return hist+ | otherwise = do+ let xss = map fst hist+ ys = map snd hist+ -- BO2: scale X to [0,1]^d for the GP only (history is+ -- still kept in raw units for f).+ xssScl = map scaleX xss+ xMat = LA.fromLists xssScl+ yVec0 = LA.fromList ys+ -- BO1: z-score y so HP optimization is scale-free+ -- (skopt normalize_y=True equivalent). Both GP fitting+ -- and EI run in normalized space; the next-x choice is+ -- scale-equivariant.+ stdr = Std.fitStandardizer (LA.asColumn yVec0)+ yVec = LA.flatten+ (Std.applyStandardizer stdr (LA.asColumn yVec0))+ yBest = LA.minElement yVec+ -- After BO2 scaling, X lives on [0, 1]^d. The natural ℓ+ -- grows as √d (mean pairwise distance scales that way),+ -- so start L-BFGS from ℓ = 0.25 √d to keep correlations+ -- meaningful as input dimension grows.+ --+ -- Phase A (true ARD): the per-dim ℓ_d API is implemented+ -- in 'Hanalyze.Model.GP.GPParams.gpLengthScales' but disabled in+ -- the BO loop because with only ~30 evaluations the+ -- per-dim L-BFGS over-fits noise and underperforms+ -- isotropic on both Branin and Hartmann6. Future tuning+ -- (e.g. tighter ℓ_d prior, isotropic-warm-start) can+ -- re-enable it by setting 'gpLengthScales = Just v'.+ p0Base = initParamsFromData (concat xssScl) (LA.toList yVec)+ ell0 = 0.25 * sqrt (fromIntegral dim)+ p0 = p0Base { gpLengthScale = ell0 }+ pOpt = optimizeGPMV kern xMat yVec p0+ params = pOpt+ -- BO core fix: precompute Cholesky factor (R) and+ -- α = Ky⁻¹ y ONCE per BO iteration. The negEI callback+ -- reuses them via 'predictFast' below; this replaces the+ -- old fitGPMV-per-call which factorised Ky on every+ -- L-BFGS step (O(n³) wasted per evaluation).+ kyMat = noiseKernelMV kern params xMat+ rChol = case Chol.cholFactor kyMat of+ Just r -> r+ Nothing ->+ -- Jitter and try again.+ let n = LA.rows xMat+ kyJ = kyMat+ + LA.scale 1e-4 (LA.ident n)+ in case Chol.cholFactor kyJ of+ Just r -> r+ Nothing -> error "BO: chol failed"+ alpha = LA.flatten+ (Chol.cholSolveWithFactor rChol+ (LA.asColumn yVec))+ sf = gpSignalVar params++ -- Predict (μ, σ, k_star, vstar) at a single x via the+ -- cached factor. vstar = Ky⁻¹ k_star is reused for both+ -- the variance and its gradient.+ predictAt xVec =+ let xScl = LA.fromList (scaleX xVec)+ xRow = LA.asRow xScl+ kStarV = LA.flatten+ (buildKernelMatrixMV kern (gpKernelParams params) xRow xMat)+ mu = LA.dot kStarV alpha+ vstar = LA.flatten+ (Chol.cholSolveWithFactor rChol+ (LA.asColumn kStarV))+ varV = max 0 (sf - LA.dot kStarV vstar)+ in (mu, sqrt varV, kStarV, vstar)++ predictMuSig xVec = let (m, s, _, _) = predictAt xVec in (m, s)++ -- Batch predict (μ, σ) at m candidate rows simultaneously.+ -- Single GEMM for K_*, single triangular solve for V,+ -- elementwise σ². Replaces m sequential predicts (m+ -- BLAS-dispatch overheads) with O(1) BLAS calls.+ predictBatchScaled+ :: LA.Matrix Double -- ^ Scaled X candidates (m × p)+ -> (LA.Vector Double, LA.Vector Double)+ predictBatchScaled xCand =+ let kStar = buildKernelMatrixMV kern (gpKernelParams params) xCand xMat -- m × n+ mus = kStar LA.#> alpha -- m+ vMat = Chol.cholSolveWithFactor rChol (LA.tr kStar) -- n × m+ -- F1: diag(kStar · vMat) without forming m×m.+ kStarDotV = KD.diagAB kStar vMat+ sigmas = LA.cmap (\v -> sqrt (max 0 (sf - v))) kStarDotV+ in (mus, sigmas)++ -- Phase C (BO4 analytic gradient): per-input partial+ -- derivatives of μ and σ w.r.t. x. Avoids the 2(p+1)+ -- function-call overhead of central differences inside+ -- the inner L-BFGS. Periodic kernel falls back to the+ -- numeric path (gradient unsupported).+ --+ -- diffs[i, d] = scaleX(x)_d − xMat[i, d]+ -- factor_i = ∂k_i/∂(diffs_i,d) / diffs_i,d (kernel-specific)+ -- ∂μ/∂x_scaled_d = (factor ⊙ α)ᵀ · diffs[:, d]+ -- ∂σ/∂x_scaled_d = −(1/σ) · (factor ⊙ vstar)ᵀ · diffs[:, d]+ -- Chain back to raw x_d via 1/(hi - lo) factor (BO2).+ gradMuSig xVec =+ let xScl = LA.fromList (scaleX xVec)+ diffs = LA.fromRows+ [ xScl - xRow | xRow <- LA.toRows xMat ]+ sqd = LA.fromList+ [ d `LA.dot` d | d <- LA.toRows diffs ]+ l = gpLengthScale params+ l2 = l * l+ kStarV = LA.flatten+ (buildKernelMatrixMV kern (gpKernelParams params)+ (LA.asRow xScl) xMat)+ factor = case kern of+ RBF ->+ LA.scale (-1 / l2) kStarV+ Matern52 ->+ let r = LA.cmap (\s ->+ sqrt (max 0 s) * sqrt 5 / l) sqd+ ef = LA.cmap exp (LA.scale (-1) r)+ c = LA.scale (-5 / (3 * l2))+ (sf `LA.scale`+ (ef * (LA.cmap (1 +) r)))+ in c+ _ ->+ LA.konst 0 (LA.size kStarV) -- Periodic/Linear/Poly: numeric fallback+ vstar = LA.flatten+ (Chol.cholSolveWithFactor rChol+ (LA.asColumn kStarV))+ mu = LA.dot kStarV alpha+ varV = max 0 (sf - LA.dot kStarV vstar)+ sg = sqrt varV+ -- ∇μ in scaled coordinates: diffsᵀ · (α ⊙ factor)+ gradMuS = LA.tr diffs LA.#> (alpha * factor)+ -- ∇σ in scaled coordinates: −(1/σ) · diffsᵀ · (vstar ⊙ factor)+ gradSgS+ | sg < 1e-12 = LA.konst 0 (LA.cols xMat)+ | otherwise = LA.scale (-1 / sg)+ (LA.tr diffs LA.#> (vstar * factor))+ -- Chain back through scaleX: ∂scaledX/∂x = 1/(hi-lo)+ invSpan = LA.fromList+ [ if hi > lo then 1 / (hi - lo) else 1+ | (lo, hi) <- bounds ]+ gradMu = LA.toList (gradMuS * invSpan)+ gradSg = LA.toList (gradSgS * invSpan)+ in (mu, sg, gradMu, gradSg)++ -- Build (negAcq, gradNegAcq) pair for each acquisition.+ -- ∂EI/∂(μ,σ) = (-Φ(z), φ(z)) so ∇EI = -Φ(z) ∇μ + φ(z) ∇σ.+ -- ∂PI/∂(μ,σ) = (-φ(z)/σ, -z·φ(z)/σ) so+ -- ∇PI = -φ(z)/σ · ∇μ - z·φ(z)/σ · ∇σ.+ -- LCB is linear: ∇LCB = ∇μ − β ∇σ.+ wrapAcqGrad+ :: ((Double, Double) -> Double) -- acq value+ -> ((Double, Double) -> (Double, Double)) -- (∂/∂μ, ∂/∂σ) of acq+ -> ([Double] -> Double, [Double] -> [Double])+ wrapAcqGrad acqFn dAcq =+ let fn xVec = unsafePerformIO $ do+ r <- try (evaluate+ (negate (acqFn (let (m, s) = predictMuSig xVec+ in (m, s)))))+ :: IO (Either SomeException Double)+ case r of { Left _ -> pure 1e30; Right v -> pure v }+ gn xVec = unsafePerformIO $ do+ r <- try (evaluate+ (let (mu, sg, gMu, gSg) = gradMuSig xVec+ (dM, dS) = dAcq (mu, sg)+ in [ - (dM * gm + dS * gs)+ | (gm, gs) <- zip gMu gSg ]))+ :: IO (Either SomeException [Double])+ case r of+ Left _ -> pure (replicate (length xVec) 0)+ Right v -> pure v+ in (fn, gn)++ eiGrad (mu, sg)+ | sg <= 1e-12 = (0, 0)+ | otherwise =+ let z = (yBest - mu - 0.01) / sg+ phi = density standard z+ cdf = cumulative standard z+ in (-cdf, phi)+ piGrad (mu, sg)+ | sg <= 1e-12 = (0, 0)+ | otherwise =+ let z = (yBest - mu - 0.01) / sg+ phi = density standard z+ in (-phi / sg, -z * phi / sg)+ lcbGrad _ = (1, -2.0) -- ∂(μ - 2σ)/∂μ = 1, ∂/∂σ = -2++ (negEI, gNegEI) = wrapAcqGrad (ei yBest 0.01) eiGrad+ (negPI, gNegPI) = wrapAcqGrad (pi_ yBest 0.01) piGrad+ -- For LCB we want to minimise μ - βσ. Wrap as the value+ -- itself (acq = -LCB), so negate(acq) = LCB.+ (negLCB, gNegLCB) =+ wrapAcqGrad (negate . ucb 2.0)+ (\ms -> let (a, b) = lcbGrad ms in (-a, -b))+ _ = unitBounds++ -- Inner acquisition optimization: original 20 Halton starts+ -- (kept for diversity; preselection via batch eval was tried+ -- in D2 but consistently regressed Hartmann6 — even with+ -- diversity injection — to a -1.83 local mode that the broad+ -- Halton scan avoids). Maxiter is reduced from 100 → 50 as+ -- a speed compromise (Branin and Hartmann6 still solid).+ haltonStarts <- pure (QR.haltonSequenceIn nStarts bounds)+ starts <- forM haltonStarts $ \xs ->+ forM (zip bounds xs) $ \((lo, hi), v) -> do+ u <- uniform gen :: IO Double+ let span_ = hi - lo+ jit = (u - 0.5) * 0.05 * span_+ pure (max lo (min hi (v + jit)))+ let useAnalytic = case kern of+ RBF -> True+ Matern52 -> True+ _ -> False -- Periodic/Linear/Poly は数値勾配+ runMSG objFn gradFn = mapM (\x0 ->+ LBFGS.runLBFGSWith+ (LBFGS.defaultLBFGSConfig+ { LBFGS.lbStop = OC.defaultStopCriteria+ { OC.stMaxIter = 50 } })+ objFn gradFn x0) starts+ runMS objFn = mapM (\x0 ->+ LBFGS.runLBFGSNumeric+ (LBFGS.defaultLBFGSConfig+ { LBFGS.lbStop = OC.defaultStopCriteria+ { OC.stMaxIter = 50 } })+ objFn x0) starts+ pickXNext rs =+ let best = minimumBy (comparing OC.orValue) rs+ xRaw = OC.orBest best+ in zipWith (\(lo, hi) v -> max lo (min hi v)) bounds xRaw+ xEI <- pickXNext <$> if useAnalytic+ then runMSG negEI gNegEI+ else runMS negEI+ xLCB <- pickXNext <$> if useAnalytic+ then runMSG negLCB gNegLCB+ else runMS negLCB+ xPI <- pickXNext <$> if useAnalytic+ then runMSG negPI gNegPI+ else runMS negPI+ let candidates = [xEI, xLCB, xPI]+ -- GP-Hedge selection.+ k <- pickAcq gains gen+ let kSafe = max 0 (min 2 k)+ xNext = candidates !! kSafe+ yNext <- f xNext+ -- Update gains: reward = -μ at each candidate (we want low μ).+ let mus = map (fst . predictMuSig) candidates+ gains' = zipWith (\g m -> g - m) gains mus+ loop (t - 1) (hist ++ [(xNext, yNext)]) gains'++ finalHist <- loop (boIterations cfg) history0 [0, 0, 0]+ let bestPair = minimumBy (comparing snd) finalHist+ return (finalHist, bestPair)++-- ---------------------------------------------------------------------------+-- Phase E1: bounded multi-restart HP optimisation+-- ---------------------------------------------------------------------------++-- | Bounded multi-restart kernel HP optimization for use inside the BO+-- loop. Mirrors skopt's @cook_estimator@ + @n_restarts_optimizer=2@:+-- runs L-BFGS-B from @n@ random log-uniform inits in+-- @log ℓ ∈ [log 0.01, log 100]@, picks the maximum-LML solution.+--+-- Compared to a single-init 'optimizeGPMV' this is significantly more+-- robust on multi-modal log-marginal-likelihood surfaces (Branin, where+-- the 3 global mins demand a sharp ℓ but the LML basin near a broad ℓ+-- is also locally optimal).+--+-- The first init is the user-provided @p0@; subsequent inits are+-- log-uniform perturbations of @p0@ over [0.01, 100].+optimizeHPMultiRestart+ :: Int -- ^ Total restarts (≥ 1)+ -> Kernel+ -> LA.Matrix Double -- ^ Training X (n × p)+ -> LA.Vector Double -- ^ Training y (length n)+ -> GPParams -- ^ Initial guess (first restart)+ -> GPParams+optimizeHPMultiRestart nRestarts kern trainX y p0 =+ let pdim = LA.cols trainX+ isARD = case gpLengthScales p0 of+ Just v | LA.size v == pdim && pdim > 0 -> True+ _ -> False+ -- log-space bounds: skopt の length_scale_bounds=(0.01, 100)+ logLo = log 0.01+ logHi = log 100+ -- σ_f² and σ_n² の bounds は緩めに (kernel HP より広い)+ logVarLo = log 1e-6+ logVarHi = log 1e6+ -- LBFGS bounds for HP vector+ hpBounds+ | isARD = replicate pdim (logLo, logHi)+ ++ [(logVarLo, logVarHi), (logVarLo, logVarHi)]+ | otherwise = [(logLo, logHi), (logVarLo, logVarHi)+ , (logVarLo, logVarHi)]+ -- Pack/unpack between [Double] (LBFGS state) and GPParams+ paramsToVec p+ | isARD = let Just v = gpLengthScales p+ ls = LA.toList v+ in map log ls+ ++ [log (gpSignalVar p), log (gpNoiseVar p)]+ | otherwise = [ log (gpLengthScale p)+ , log (gpSignalVar p)+ , log (gpNoiseVar p) ]+ vecToParams u+ | isARD =+ let lsV = LA.fromList (map exp (take pdim u))+ in p0+ { gpLengthScales = Just lsV+ , gpSignalVar = exp (u !! pdim)+ , gpNoiseVar = exp (u !! (pdim + 1))+ }+ | otherwise = p0+ { gpLengthScale = exp (u !! 0)+ , gpSignalVar = exp (u !! 1)+ , gpNoiseVar = exp (u !! 2)+ }+ -- Negative LML to minimise (LBFGS minimises by default).+ negLML u = - logMarginalLikelihoodMV trainX y kern (vecToParams u)+ -- Build restart inits: keep σ_f²/σ_n² at p0, vary ℓ over a few+ -- fixed log-spaced points (Branin needs sharp ℓ near 0.1, others+ -- benefit from broad ℓ near 1-10).+ p0Vec = paramsToVec p0+ sigfLog = p0Vec !! pdim -- (paramsToVec layout) for ARD+ signLog = p0Vec !! (pdim + 1)+ sigfLogIso = p0Vec !! 1+ signLogIso = p0Vec !! 2+ ellGrid = take (max 0 (nRestarts - 1)) [log 0.1, log 1.0, log 10.0]+ mkInit ll+ | isARD = replicate pdim ll ++ [sigfLog, signLog]+ | otherwise = [ll, sigfLogIso, signLogIso]+ inits = p0Vec : map mkInit ellGrid+ cfg = LBFGS.defaultLBFGSConfig+ { LBFGS.lbStop = OC.defaultStopCriteria+ { OC.stMaxIter = 50, OC.stTolFun = 1e-7 }+ , LBFGS.lbBounds = Just hpBounds+ }+ runOne u0 = unsafePerformIO $ LBFGS.runLBFGSNumeric cfg negLML u0+ results = map runOne inits+ -- Pick the lowest-negLML result (= highest LML)+ best = minimumBy (comparing OC.orValue) results+ in vecToParams (OC.orBest best)++-- | [日本語]: Multi-objective BO using __scalarization__ (ParEGO-style)。+-- 各反復で random 重み w で Tchebycheff scalarize し、単目的 BO の 1 ステップ+-- (L-BFGS multi-start で acquisition 最大化) を実行する。+-- NSGA 版より高速、acquisition 計算コストが軽い問題に向く。+-- [English]: Multi-objective BO using __scalarization__ (ParEGO-style).+-- Each iteration performs a Tchebycheff scalarization with a random weight+-- w, then executes a single-objective BO step (maximizing acquisition via+-- L-BFGS multi-start). Faster than the NSGA version; suited to problems+-- where acquisition evaluation is cheap.+bayesOptScalarMO :: Int -- iter+ -> Int -- nInit+ -> Int -- nStarts (multi-start)+ -> Kernel+ -> ([Double] -> IO [Double])+ -> Bounds+ -> GenIO+ -> IO [([Double], [Double])]+bayesOptScalarMO nIter nInit nStarts kern f bounds gen = do+ initX <- replicateM nInit (forM bounds $ \(lo, hi) -> do+ u <- uniform gen :: IO Double+ return (lo + u * (hi - lo)))+ initY <- mapM f initX+ let history0 = zip initX initY++ step hist = do+ let xss = map fst hist+ ysAll = map snd hist+ qDim = length (head ysAll)+ xsFlat = map head xss -- 1D 入力前提の簡易版+ ysCol j = [y !! j | y <- ysAll]+ -- random scalarization weight+ wsRaw <- replicateM qDim (uniform gen :: IO Double)+ let wSum = sum wsRaw+ ws = map (/ wSum) wsRaw+ -- 各目的の GP fit (1D 入力)+ modelFor j =+ let trainY = ysCol j+ p0 = initParamsFromData xsFlat trainY+ pOpt = optimizeGP kern xsFlat trainY p0+ in GPModel kern pOpt+ models = [(modelFor j, ysCol j) | j <- [0 .. qDim - 1]]+ -- Tchebycheff: max_j w_j (μ_j - z*_j) — z*_j は最良観測+ zStars = [minimum (ysCol j) | j <- [0 .. qDim - 1]]+ scalarLcb xVec = unsafePerformIO $ do+ let xkey = head xVec+ computeOne j = do+ let (m, ty) = models !! j+ r = fitGP m xsFlat ty [xkey]+ mu = head (gpMean r)+ sg = (head (gpUpper r) - mu) / 2+ lcb = mu - 2.0 * sg+ _ <- evaluate mu; _ <- evaluate sg+ pure ((ws !! j) * (lcb - (zStars !! j)))+ safe j = do+ res <- try (computeOne j) :: IO (Either SomeException Double)+ case res of { Left _ -> pure 1e30; Right v -> pure v }+ perJ <- mapM safe [0 .. qDim - 1]+ pure (maximum perJ)+ -- L-BFGS multi-start で scalarLcb 最小化+ starts <- replicateM nStarts (forM bounds $ \(lo, hi) -> do+ u <- uniform gen :: IO Double+ return (lo + u * (hi - lo)))+ results <- mapM (\x0 ->+ LBFGS.runLBFGSNumeric+ (LBFGS.defaultLBFGSConfig+ { LBFGS.lbStop = OC.defaultStopCriteria { OC.stMaxIter = 60 } })+ scalarLcb x0) starts+ let best = minimumBy (comparing OC.orValue) results+ xNextRaw = OC.orBest best+ xNext = zipWith (\(lo, hi) v -> max lo (min hi v)) bounds xNextRaw+ yNext <- f xNext+ return (hist ++ [(xNext, yNext)])++ loop t h+ | t == 0 = return h+ | otherwise = step h >>= loop (t - 1)++ loop nIter history0++argmax :: Ord a => [a] -> Int+argmax xs = snd (maximum (zip xs [0..]))++-- ---------------------------------------------------------------------------+-- 多目的 BO with NSGA-II (Phase V4)+-- ---------------------------------------------------------------------------++-- | Multi-objective BO using NSGA-II to optimize the acquisition function.+--+-- Internally fits a @MultiGP@ to obtain per-objective @(μ, σ)@, then+-- runs NSGA-II to find the Pareto front in @(μ_1, μ_2, ...)@ space; one+-- point from that front is chosen and evaluated.+--+-- A deliberately simple implementation; an EHVI-based variant is left+-- for future extension.+bayesOptMOWithNSGA+ :: Int -- ^ Number of BO iterations.+ -> Int -- ^ Number of initial samples.+ -> Kernel+ -> ([Double] -> IO [Double]) -- ^ Multi-objective function.+ -> Bounds+ -> GenIO+ -> IO [([Double], [Double])] -- ^ Sequence of @(x, y)@ pairs.+bayesOptMOWithNSGA nIter nInit kern f bounds gen = do+ -- 初期点+ initX <- replicateM nInit (do+ vs <- forM bounds $ \(lo, hi) -> do+ u <- uniform gen :: IO Double+ return (lo + u * (hi - lo))+ return vs)+ initY <- mapM f initX+ let history0 = zip initX initY++ let loop t hist+ | t == 0 = return hist+ | otherwise = do+ -- 各目的に GP を fit (1D 入力前提の簡易版)+ -- 多次元入力の場合は MultiGP を別途準備+ -- ここでは bounds の最初の次元のみ使う簡易動作+ let xsFlat = map head (map fst hist) -- 1D 入力前提+ ysAll = map snd hist+ qDim = length (head ysAll)+ ysCol j = [y !! j | y <- ysAll]++ -- 各目的 j の GP モデルを fit+ let modelFor j =+ let trainY = ysCol j+ p0 = initParamsFromData xsFlat trainY+ pOpt = optimizeGP kern xsFlat trainY p0+ in GPModel kern pOpt++ models = [modelFor j | j <- [0 .. qDim - 1]]++ -- NSGA-II で Pareto front を探索 (acquisition surface 上)+ -- 各目的: μ - β σ (LCB) を最小化+ acqObjective xVec =+ [ unsafePerformIO $ do+ let computed = do+ let trainY = ysCol j+ m = models !! j+ gpRes = fitGP m xsFlat trainY [head xVec]+ mu = head (gpMean gpRes)+ sg = (head (gpUpper gpRes) - mu) / 2+ _ <- evaluate mu; _ <- evaluate sg+ pure (ucbToMin mu sg)+ r <- try computed :: IO (Either SomeException Double)+ case r of { Left _ -> pure 1e30; Right v -> pure v }+ | j <- [0 .. qDim - 1] ]++ ucbToMin :: Double -> Double -> Double+ ucbToMin mu sigma = mu - 2.0 * sigma -- LCB++ -- NSGA-II で Pareto front を 1 ステップ探索+ front <- nsga2 (defaultNSGAConfig { nsgaPopSize = 30+ , nsgaGenerations = 30 })+ acqObjective bounds gen++ -- front から random 選択+ idx <- uniform gen :: IO Double+ let i = floor (idx * fromIntegral (length front))+ xNext = solDecision (front !! min i (length front - 1))+ yNext <- f xNext+ loop (t - 1) (hist ++ [(xNext, yNext)])++ loop nIter history0
+ src/Hanalyze/Stat/Causal/CATE.hs view
@@ -0,0 +1,224 @@+-- |+-- Module : Hanalyze.Stat.Causal.CATE+-- Description : Künzel et al. (2019) の S/T/X-Learner による CATE meta-learner 実装+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- [日本語]: Conditional Average Treatment Effect (CATE) meta-learners。+--+-- Künzel et al. (2019) の 3 meta-learner を実装:+--+-- - 'SLearner': 単一モデル @μ̂(X, T)@、 @τ̂(X) = μ̂(X, 1) - μ̂(X, 0)@+-- - 'TLearner': 2 モデル @μ̂_1(X)@ / @μ̂_0(X)@、 @τ̂(X) = μ̂_1(X) - μ̂_0(X)@+-- - 'XLearner': T-learner の残差を再帰回帰、 PS で重み付け平均+--+-- base learner は 'CATELM' (= 'Hanalyze.Model.LM') と 'CATERF' (=+-- 'Hanalyze.Model.RandomForest') から選択。 将来 Causal Forest 等を追加する+-- ときは新 constructor を加える。+--+-- ## 使い方+--+-- @+-- gen <- MWC.create+-- r <- fitCATE TLearner CATELM x t y gen+-- print (cateATE r) -- average of cateEstimates+-- @+--+-- Reference:+-- Künzel, Sekhon, Bickel, Yu (2019) "Metalearners for estimating+-- heterogeneous treatment effects using machine learning".+-- PNAS 116:4156-4165.+--+-- [English]: Conditional Average Treatment Effect (CATE) meta-learners.+--+-- Implements the 3 meta-learners from Künzel et al. (2019):+--+-- - 'SLearner': a single model @μ̂(X, T)@, @τ̂(X) = μ̂(X, 1) - μ̂(X, 0)@+-- - 'TLearner': two models @μ̂_1(X)@ \/ @μ̂_0(X)@, @τ̂(X) = μ̂_1(X) - μ̂_0(X)@+-- - 'XLearner': regresses the T-learner's residuals again, then averages+-- with propensity-score weighting+--+-- The base learner is chosen from 'CATELM' (= 'Hanalyze.Model.LM')+-- and 'CATERF' (= 'Hanalyze.Model.RandomForest'). When adding+-- Causal Forest or similar in the future, add a new constructor.+--+-- ## Usage+--+-- @+-- gen <- MWC.create+-- r <- fitCATE TLearner CATELM x t y gen+-- print (cateATE r) -- average of cateEstimates+-- @+--+-- Reference:+-- Künzel, Sekhon, Bickel, Yu (2019) "Metalearners for estimating+-- heterogeneous treatment effects using machine learning".+-- PNAS 116:4156-4165.+module Hanalyze.Stat.Causal.CATE+ ( CATEBaseLearner (..)+ , CATELearner (..)+ , CATEResult (..)+ , fitCATE+ ) where++import qualified Numeric.LinearAlgebra as LA+import qualified Data.Vector.Storable as VS+import qualified Data.Vector.Unboxed as VU+import qualified Hanalyze.Model.LM as LM+import qualified Hanalyze.Model.RandomForest as RF+import Hanalyze.Model.Core (coefficientsV)+import Hanalyze.Stat.Causal.PropensityScore+ (PropensityScore (..), propensityScore, trimPropensity)+import Hanalyze.Stat.Causal.IPW (defaultPSTrim)+import qualified System.Random.MWC as MWC++-- ---------------------------------------------------------------------------+-- 型+-- ---------------------------------------------------------------------------++-- | [日本語]: base learner 選択。 LM は OLS、 RF は Random Forest。+-- [English]: Base learner selection. LM is OLS, RF is Random Forest.+data CATEBaseLearner = CATELM | CATERF RF.RFConfig+ deriving (Show)++-- | [日本語]: meta-learner 選択。 [English]: Meta-learner selection.+data CATELearner = SLearner | TLearner | XLearner+ deriving (Show, Eq)++data CATEResult = CATEResult+ { cateEstimates :: !(LA.Vector Double) -- ^ τ̂(X_i) for each unit+ , cateMethod :: !CATELearner+ , cateATE :: !Double -- ^ mean of cateEstimates+ } deriving (Show)++-- ---------------------------------------------------------------------------+-- Base learner abstraction+-- ---------------------------------------------------------------------------++-- | Train a base learner on (X, y) and return a predictor for new X.+-- Random forest path threads through @MWC.GenIO@; LM is pure but is+-- wrapped in @IO@ for uniform signature.+fitPredict :: CATEBaseLearner+ -> LA.Matrix Double -> LA.Vector Double -> MWC.GenIO+ -> IO (LA.Matrix Double -> LA.Vector Double)+fitPredict CATELM x y _ = do+ let beta = coefficientsV (LM.fitLMVec x y)+ pure (\xNew -> LM.predictLMVec beta xNew)+fitPredict (CATERF cfg) x y gen = do+ rf <- RF.fitRFV cfg x (VS.convert y :: VU.Vector Double)+ gen+ pure (\xNew ->+ let rows = LA.toRows xNew+ in LA.fromList [RF.predictRF rf (LA.toList r) | r <- rows])++-- ---------------------------------------------------------------------------+-- fitCATE+-- ---------------------------------------------------------------------------++fitCATE :: CATELearner -> CATEBaseLearner+ -> LA.Matrix Double -> LA.Vector Double -> LA.Vector Double+ -> MWC.GenIO -> IO CATEResult+fitCATE method base x t y gen = case method of+ SLearner -> sLearner base x t y gen+ TLearner -> tLearner base x t y gen+ XLearner -> xLearner base x t y gen++-- ---------------------------------------------------------------------------+-- S-learner: 単一モデル on (X, T)+-- ---------------------------------------------------------------------------++sLearner :: CATEBaseLearner+ -> LA.Matrix Double -> LA.Vector Double -> LA.Vector Double+ -> MWC.GenIO -> IO CATEResult+sLearner base x t y gen = do+ let xt = LA.fromBlocks [[x, LA.asColumn t]]+ n = LA.rows x+ x1 = LA.fromBlocks [[x, LA.asColumn (LA.fromList (replicate n 1))]]+ x0 = LA.fromBlocks [[x, LA.asColumn (LA.fromList (replicate n 0))]]+ predict <- fitPredict base xt y gen+ let mu1 = predict x1+ mu0 = predict x0+ tauHat = mu1 - mu0+ pure CATEResult+ { cateEstimates = tauHat+ , cateMethod = SLearner+ , cateATE = LA.sumElements tauHat / fromIntegral n+ }++-- ---------------------------------------------------------------------------+-- T-learner: 2 モデル、 群別 fit+-- ---------------------------------------------------------------------------++tLearner :: CATEBaseLearner+ -> LA.Matrix Double -> LA.Vector Double -> LA.Vector Double+ -> MWC.GenIO -> IO CATEResult+tLearner base x t y gen = do+ let n = LA.rows x+ idx1 = filterIdx (== 1.0) t+ idx0 = filterIdx (== 0.0) t+ x1 = x LA.? idx1+ y1 = LA.fromList [LA.atIndex y i | i <- idx1]+ x0 = x LA.? idx0+ y0 = LA.fromList [LA.atIndex y i | i <- idx0]+ pred1 <- fitPredict base x1 y1 gen+ pred0 <- fitPredict base x0 y0 gen+ let mu1 = pred1 x+ mu0 = pred0 x+ tauHat = mu1 - mu0+ pure CATEResult+ { cateEstimates = tauHat+ , cateMethod = TLearner+ , cateATE = LA.sumElements tauHat / fromIntegral n+ }++-- ---------------------------------------------------------------------------+-- X-learner: 残差再回帰 + PS 重み付け+-- ---------------------------------------------------------------------------++xLearner :: CATEBaseLearner+ -> LA.Matrix Double -> LA.Vector Double -> LA.Vector Double+ -> MWC.GenIO -> IO CATEResult+xLearner base x t y gen = do+ let n = LA.rows x+ idx1 = filterIdx (== 1.0) t+ idx0 = filterIdx (== 0.0) t+ x1 = x LA.? idx1+ y1 = LA.fromList [LA.atIndex y i | i <- idx1]+ x0 = x LA.? idx0+ y0 = LA.fromList [LA.atIndex y i | i <- idx0]+ -- Step 1: T-learner と同じ outcome models+ pred1 <- fitPredict base x1 y1 gen+ pred0 <- fitPredict base x0 y0 gen+ -- Step 2: imputed treatment effects+ -- For T=1 units: D̃_1 = Y - μ̂_0(X)+ -- For T=0 units: D̃_0 = μ̂_1(X) - Y+ let mu0_at_x1 = pred0 x1+ mu1_at_x0 = pred1 x0+ dTilde1 = y1 - mu0_at_x1+ dTilde0 = mu1_at_x0 - y0+ -- Step 3: τ̂_1(X) を D̃_1 ~ X_{T=1} で fit、 τ̂_0(X) は D̃_0 ~ X_{T=0}+ tau1Pred <- fitPredict base x1 dTilde1 gen+ tau0Pred <- fitPredict base x0 dTilde0 gen+ let tau1At = tau1Pred x+ tau0At = tau0Pred x+ -- Step 4: PS 重み付け平均+ -- τ̂(X) = p̂(X) · τ̂_0(X) + (1 - p̂(X)) · τ̂_1(X)+ -- (treated が少ない領域では τ̂_0 を信頼、 control が少ない領域では τ̂_1)+ (lo, hi) = defaultPSTrim+ let ps = trimPropensity lo hi (propensityScore x t)+ p = psScores ps+ one = LA.scalar 1+ tauHat = p * tau0At + (one - p) * tau1At+ pure CATEResult+ { cateEstimates = tauHat+ , cateMethod = XLearner+ , cateATE = LA.sumElements tauHat / fromIntegral n+ }++-- ---------------------------------------------------------------------------+-- ヘルパ+-- ---------------------------------------------------------------------------++filterIdx :: (Double -> Bool) -> LA.Vector Double -> [Int]+filterIdx pr v =+ [ i | i <- [0 .. LA.size v - 1], pr (LA.atIndex v i) ]
+ src/Hanalyze/Stat/Causal/DoublyRobust.hs view
@@ -0,0 +1,127 @@+-- |+-- Module : Hanalyze.Stat.Causal.DoublyRobust+-- Description : Doubly Robust / Augmented IPW (AIPW) 推定量+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- [日本語]: Doubly Robust / Augmented IPW (AIPW) 推定量。+--+-- 結果モデル @μ̂_1(X)@ / @μ̂_0(X)@ と傾向スコア @p̂(X)@ の両方を使い、+-- どちらか一方が正しく指定されていれば一致性を持つ推定量:+--+-- @+-- ATE_AIPW = (1/n) Σ [ μ̂_1(X_i) - μ̂_0(X_i)+-- + T_i (Y_i - μ̂_1(X_i)) / p̂_i+-- - (1-T_i) (Y_i - μ̂_0(X_i)) / (1 - p̂_i) ]+-- @+--+-- 結果モデルは 'Hanalyze.Model.LM.fitLM' を流用 (= OLS、 線形)。 非線形が+-- 必要な場合は呼び出し側で X を拡張するか CATE module を使う。+--+-- Reference:+-- Robins, Rotnitzky, Zhao (1994) "Estimation of Regression Coefficients+-- When Some Regressors Are Not Always Observed". JASA 89:846-866.+--+-- [English]: The Doubly Robust \/ Augmented IPW (AIPW) estimator.+--+-- Uses both the outcome models @μ̂_1(X)@ \/ @μ̂_0(X)@ and the propensity+-- score @p̂(X)@; consistent if either one is correctly specified:+--+-- @+-- ATE_AIPW = (1/n) Σ [ μ̂_1(X_i) - μ̂_0(X_i)+-- + T_i (Y_i - μ̂_1(X_i)) / p̂_i+-- - (1-T_i) (Y_i - μ̂_0(X_i)) / (1 - p̂_i) ]+-- @+--+-- The outcome model reuses 'Hanalyze.Model.LM.fitLM' (= OLS,+-- linear). If nonlinearity is needed, the caller should expand X or+-- use the CATE module.+--+-- Reference:+-- Robins, Rotnitzky, Zhao (1994) "Estimation of Regression+-- Coefficients When Some Regressors Are Not Always Observed".+-- JASA 89:846-866.+module Hanalyze.Stat.Causal.DoublyRobust+ ( DoublyRobustResult (..)+ , doublyRobust+ , doublyRobustWith+ ) where++import qualified Numeric.LinearAlgebra as LA+import qualified Hanalyze.Model.LM as LM+import Hanalyze.Model.Core (coefficientsV)+import Hanalyze.Stat.Causal.PropensityScore+ (PropensityScore (..), propensityScore, trimPropensity)+import Hanalyze.Stat.Causal.IPW (defaultPSTrim)++-- ---------------------------------------------------------------------------+-- 型+-- ---------------------------------------------------------------------------++data DoublyRobustResult = DoublyRobustResult+ { drATE :: !Double+ , drMu1Predicted :: !(LA.Vector Double) -- ^ μ̂_1(X_i) for all i+ , drMu0Predicted :: !(LA.Vector Double) -- ^ μ̂_0(X_i) for all i+ , drPropensity :: !PropensityScore+ } deriving (Show)++-- ---------------------------------------------------------------------------+-- AIPW+-- ---------------------------------------------------------------------------++-- | [日本語]: 共変量 @X@ (intercept 列を含む)、 二値処置 @T@、 結果 @Y@ から AIPW ATE+-- を推定。 内部で 'propensityScore' + 'defaultPSTrim' を適用、 outcome+-- model は OLS で群別 fit。+--+-- [English]: Estimates the AIPW ATE from covariates @X@ (including an+-- intercept column), binary treatment @T@, and outcome @Y@. Internally+-- applies 'propensityScore' + 'defaultPSTrim'; the outcome model is+-- fit via OLS per group.+doublyRobust :: LA.Matrix Double -> LA.Vector Double -> LA.Vector Double+ -> DoublyRobustResult+doublyRobust x t y =+ let (lo, hi) = defaultPSTrim+ ps = trimPropensity lo hi (propensityScore x t)+ in doublyRobustWith ps x t y++-- | [日本語]: 既存 PS を再利用する版。 PS と outcome model の組み合わせを変えて+-- 二重ロバスト性を検証したい場合に有用。+-- [English]: A variant that reuses an existing propensity score. Useful when+-- verifying double robustness by varying the combination of the propensity+-- score and the outcome model.+doublyRobustWith :: PropensityScore -> LA.Matrix Double -> LA.Vector Double+ -> LA.Vector Double -> DoublyRobustResult+doublyRobustWith ps x t y =+ let n = fromIntegral (LA.size t) :: Double+ one = LA.scalar 1+ p = psScores ps+ -- 群別 OLS: T=1 部分集合 / T=0 部分集合+ idx1 = filterIdx (== 1.0) t+ idx0 = filterIdx (== 0.0) t+ x1 = x LA.? idx1+ y1 = LA.fromList [LA.atIndex y i | i <- idx1]+ x0 = x LA.? idx0+ y0 = LA.fromList [LA.atIndex y i | i <- idx0]+ beta1 = coefficientsV (LM.fitLMVec x1 y1)+ beta0 = coefficientsV (LM.fitLMVec x0 y0)+ mu1 = LM.predictLMVec beta1 x+ mu0 = LM.predictLMVec beta0 x+ -- AIPW contribution per unit+ contrib = (mu1 - mu0)+ + t * (y - mu1) / p+ - (one - t) * (y - mu0) / (one - p)+ ateHat = LA.sumElements contrib / n+ in DoublyRobustResult+ { drATE = ateHat+ , drMu1Predicted = mu1+ , drMu0Predicted = mu0+ , drPropensity = ps+ }++-- ---------------------------------------------------------------------------+-- ヘルパ+-- ---------------------------------------------------------------------------++filterIdx :: (Double -> Bool) -> LA.Vector Double -> [Int]+filterIdx pr v =+ [ i | i <- [0 .. LA.size v - 1], pr (LA.atIndex v i) ]
+ src/Hanalyze/Stat/Causal/IPW.hs view
@@ -0,0 +1,135 @@+-- |+-- Module : Hanalyze.Stat.Causal.IPW+-- Description : Inverse Probability Weighting (IPW) による ATE / ATT 推定+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- [日本語]: Inverse Probability Weighting (IPW) による ATE / ATT 推定。+--+-- Hajek 正規化推定量 (finite-sample で stable、 Horvitz-Thompson より低分散):+--+-- @+-- ATE = Σ(T·Y/p) / Σ(T/p) - Σ((1-T)·Y/(1-p)) / Σ((1-T)/(1-p))+-- ATT = Σ(T·Y) / Σ T - Σ((1-T)·(p/(1-p))·Y) / Σ((1-T)·(p/(1-p)))+-- @+--+-- ここで @p_i@ は 'PropensityScore' で推定した P(T=1 | X_i)。 重みは+-- @PropensityScore.ipwWeights@ / @attWeights@ で hmatrix Vector 演算で計算。+--+-- ## 使い方+--+-- @+-- let r = ipw xConf treat outcome -- 共変量から PS 推定 + trim も内部で実施+-- print (ipwATE r, ipwATT r)+--+-- -- 既に PS を計算済 / カスタム trim したい場合:+-- let ps' = trimPropensity 0.05 0.95 (propensityScore x t)+-- r' = ipwWith ps' t y+-- @+--+-- [English]: ATE\/ATT estimation via Inverse Probability Weighting (IPW).+--+-- Hajek-normalized estimator (stable in finite samples, lower variance+-- than Horvitz-Thompson):+--+-- @+-- ATE = Σ(T·Y/p) / Σ(T/p) - Σ((1-T)·Y/(1-p)) / Σ((1-T)/(1-p))+-- ATT = Σ(T·Y) / Σ T - Σ((1-T)·(p/(1-p))·Y) / Σ((1-T)·(p/(1-p)))+-- @+--+-- Here @p_i@ is P(T=1 | X_i) estimated by 'PropensityScore'. The weights+-- are computed with hmatrix Vector operations via+-- @PropensityScore.ipwWeights@ \/ @attWeights@.+--+-- ## Usage+--+-- @+-- let r = ipw xConf treat outcome -- estimates PS from covariates + trims internally+-- print (ipwATE r, ipwATT r)+--+-- -- If the PS is already computed \/ a custom trim is wanted:+-- let ps' = trimPropensity 0.05 0.95 (propensityScore x t)+-- r' = ipwWith ps' t y+-- @+--+-- Reference:+-- Horvitz & Thompson (1952) "A Generalization of Sampling Without+-- Replacement from a Finite Universe". JASA 47:663-685.+module Hanalyze.Stat.Causal.IPW+ ( IPWResult (..)+ , ipw+ , ipwWith+ , defaultPSTrim+ ) where++import qualified Numeric.LinearAlgebra as LA+import Hanalyze.Stat.Causal.PropensityScore+ (PropensityScore (..), propensityScore, trimPropensity,+ ipwWeights, attWeights)++-- ---------------------------------------------------------------------------+-- 型+-- ---------------------------------------------------------------------------++data IPWResult = IPWResult+ { ipwATE :: !Double+ , ipwATT :: !Double+ , ipwWeightsATE :: !(LA.Vector Double)+ , ipwWeightsATT :: !(LA.Vector Double)+ , ipwPropensity :: !PropensityScore+ } deriving (Show)++-- | [日本語]: 既定の PS trim 範囲 @(0.01, 0.99)@ (Rosenbaum 慣例)。+-- [English]: Default PS trim range @(0.01, 0.99)@ (Rosenbaum convention).+defaultPSTrim :: (Double, Double)+defaultPSTrim = (0.01, 0.99)++-- ---------------------------------------------------------------------------+-- 推定+-- ---------------------------------------------------------------------------++-- | [日本語]: 共変量 @X@、 二値処置 @T@、 結果 @Y@ から ATE / ATT を IPW で推定。+-- 内部で 'propensityScore' + 'defaultPSTrim' を適用。+-- [English]: Estimate ATE\/ATT via IPW from covariates @X@, binary+-- treatment @T@, and outcome @Y@. Applies 'propensityScore' ++-- 'defaultPSTrim' internally.+ipw :: LA.Matrix Double -> LA.Vector Double -> LA.Vector Double -> IPWResult+ipw x t y =+ let (lo, hi) = defaultPSTrim+ ps = trimPropensity lo hi (propensityScore x t)+ in ipwWith ps t y++-- | [日本語]: 既に算出 (+trim) 済の PropensityScore を再利用する版。 同じ X から+-- ATE / ATT を複数バリアントで比べたい場合に有用。+-- [English]: A variant that reuses an already-computed (+trimmed)+-- PropensityScore. Useful for comparing ATE\/ATT across multiple+-- variants from the same X.+ipwWith :: PropensityScore -> LA.Vector Double -> LA.Vector Double -> IPWResult+ipwWith ps t y =+ let p = psScores ps+ one = LA.scalar 1+ wATE = ipwWeights ps t+ wATT = attWeights ps t+ -- ATE (Hajek 正規化): 各群の重み付き平均の差+ -- μ̂_1 = Σ (T/p)·Y / Σ (T/p)+ -- μ̂_0 = Σ ((1-T)/(1-p))·Y / Σ ((1-T)/(1-p))+ w1 = t / p+ w0 = (one - t) / (one - p)+ mu1Hat = safeDiv (LA.sumElements (w1 * y)) (LA.sumElements w1)+ mu0Hat = safeDiv (LA.sumElements (w0 * y)) (LA.sumElements w0)+ ateHat = mu1Hat - mu0Hat+ -- ATT (Hajek 正規化): treated 平均と、 p/(1-p) で再重み付けした control 平均の差+ wt1 = t -- treated indicator+ wt0 = (one - t) * (p / (one - p))+ attMu1 = safeDiv (LA.sumElements (wt1 * y)) (LA.sumElements wt1)+ attMu0 = safeDiv (LA.sumElements (wt0 * y)) (LA.sumElements wt0)+ attHat = attMu1 - attMu0+ in IPWResult+ { ipwATE = ateHat+ , ipwATT = attHat+ , ipwWeightsATE = wATE+ , ipwWeightsATT = wATT+ , ipwPropensity = ps+ }+ where+ safeDiv num den = if abs den < 1e-12 then 0 else num / den
+ src/Hanalyze/Stat/Causal/PropensityScore.hs view
@@ -0,0 +1,120 @@+-- |+-- Module : Hanalyze.Stat.Causal.PropensityScore+-- Description : logistic regression による Propensity Score P(T=1|X) の推定+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- [日本語]: Propensity Score の推定。+--+-- @p_i = P(T = 1 | X_i)@ を logistic regression (GLM Binomial+Logit) で+-- 推定する。 観測研究での因果効果推定 (IPW / AIPW / CATE) の前提となる+-- 共変量バランス指標。+--+-- ## 使い方+--+-- @+-- let ps = propensityScore xConf treat+-- ps' = trimPropensity 0.01 0.99 ps -- 重み発散防止+-- w = ipwWeights ps' treat -- t/p + (1-t)/(1-p)+-- @+--+-- Reference:+-- Rosenbaum & Rubin (1983) "The Central Role of the Propensity Score in+-- Observational Studies for Causal Effects". Biometrika 70:41-55.+--+-- [English]: Estimation of the Propensity Score.+--+-- Estimates @p_i = P(T = 1 | X_i)@ via logistic regression (GLM+-- Binomial+Logit). This is the covariate-balance measure that underlies+-- causal-effect estimation in observational studies (IPW \/ AIPW \/ CATE).+--+-- ## Usage+--+-- @+-- let ps = propensityScore xConf treat+-- ps' = trimPropensity 0.01 0.99 ps -- prevent weight divergence+-- w = ipwWeights ps' treat -- t/p + (1-t)/(1-p)+-- @+--+-- Reference:+-- Rosenbaum & Rubin (1983) "The Central Role of the Propensity Score in+-- Observational Studies for Causal Effects". Biometrika 70:41-55.+module Hanalyze.Stat.Causal.PropensityScore+ ( PropensityScore (..)+ , propensityScore+ , trimPropensity+ , ipwWeights+ , attWeights+ ) where++import qualified Numeric.LinearAlgebra as LA+import qualified Hanalyze.Model.GLM as GLM+import Hanalyze.Model.Core (coefficientsV, fittedV)++-- ---------------------------------------------------------------------------+-- 型+-- ---------------------------------------------------------------------------++data PropensityScore = PropensityScore+ { psScores :: !(LA.Vector Double) -- ^ [日本語]: @p_i = P(T=1|X_i)@、 長さ @n@。 [English]: @p_i = P(T=1|X_i)@, length @n@.+ , psBeta :: !(LA.Vector Double) -- ^ [日本語]: logistic coefficients。 [English]: The logistic coefficients.+ , psN :: !Int -- ^ [日本語]: サンプル数。 [English]: The sample count.+ } deriving (Show)++-- ---------------------------------------------------------------------------+-- 推定+-- ---------------------------------------------------------------------------++-- | [日本語]: 共変量行列 @X@ (intercept 列は呼び出し側で付加) と二値処置 @T ∈ {0,1}@+-- から logistic regression で傾向スコアを推定。+--+-- @X@ は @n × p@、 @T@ は長さ @n@ の 0/1 vector。 intercept が欲しい場合は+-- @1@ 列を先頭に prepend して渡す。+-- [English]: Estimates the propensity score via logistic regression from+-- a covariate matrix @X@ (the intercept column must be added by the+-- caller) and a binary treatment @T ∈ {0,1}@.+--+-- @X@ is @n × p@, @T@ is a length-@n@ 0\/1 vector. If an intercept is+-- desired, prepend a @1@ column and pass that in.+propensityScore :: LA.Matrix Double -> LA.Vector Double -> PropensityScore+propensityScore x t =+ let (fit, _) = GLM.fitGLMFull GLM.Binomial GLM.Logit x t+ in PropensityScore+ { psScores = fittedV fit+ , psBeta = coefficientsV fit+ , psN = LA.size t+ }++-- | [日本語]: @[lo, hi]@ に clip。 @p_i@ が 0 / 1 に張り付くと IPW 重みが発散する+-- ので必須。 推奨値: @lo = 0.01@, @hi = 0.99@。+-- [English]: Clips to @[lo, hi]@. Necessary because IPW weights diverge+-- when @p_i@ sticks to 0 \/ 1. Recommended values: @lo = 0.01@, @hi = 0.99@.+trimPropensity :: Double -> Double -> PropensityScore -> PropensityScore+trimPropensity lo hi ps =+ ps { psScores = LA.cmap (clamp lo hi) (psScores ps) }+ where+ clamp a b v = max a (min b v)++-- ---------------------------------------------------------------------------+-- 重み (hmatrix Vector 演算)+-- ---------------------------------------------------------------------------++-- | [日本語]: ATE 用の Horvitz-Thompson 重み: @w_i = t_i/p_i + (1-t_i)/(1-p_i)@+-- [English]: The Horvitz-Thompson weight for ATE:+-- @w_i = t_i/p_i + (1-t_i)/(1-p_i)@.+ipwWeights :: PropensityScore -> LA.Vector Double -> LA.Vector Double+ipwWeights ps t =+ let p = psScores ps+ one = LA.scalar 1+ in t / p + (one - t) / (one - p)++-- | [日本語]: ATT 用の重み: @w_i = t_i + (1-t_i) · p_i/(1-p_i)@+-- (treated は重み 1、 control は odds ratio で再重み付け)+-- [English]: The weight for ATT: @w_i = t_i + (1-t_i) · p_i/(1-p_i)@+-- (treated units get weight 1; control units are reweighted by the odds+-- ratio).+attWeights :: PropensityScore -> LA.Vector Double -> LA.Vector Double+attWeights ps t =+ let p = psScores ps+ one = LA.scalar 1+ in t + (one - t) * p / (one - p)
+ src/Hanalyze/Stat/ModelSelect.hs view
@@ -0,0 +1,471 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RankNTypes #-}+-- |+-- Module : Hanalyze.Stat.ModelSelect+-- Description : MCMC ベースのモデル比較基準 (WAIC / PSIS-LOO / pseudo-BMA)+-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License : BSD-3-Clause+--+-- MCMC-based model comparison criteria.+--+-- Provides WAIC (Widely Applicable Information Criterion) and PSIS-LOO+-- (Pareto-Smoothed Importance Sampling LOO-CV), plus a @pm.compare@-style+-- weighting facility (pseudo-BMA / stacking).+--+-- References:+--+-- - Watanabe (2010) — WAIC.+-- - Vehtari, Gelman, Gabry (2017) — PSIS-LOO.+-- - Hosking & Wallis (1987) — generalized Pareto moment estimator.+--+-- @+-- let logLikMat = chainLogLikMatrix model chain -- [[Double]]+-- print (waic logLikMat)+-- print (loo logLikMat)+-- @+module Hanalyze.Stat.ModelSelect+ ( -- * WAIC+ WAICResult (..)+ , waic+ , chainWAIC+ -- * LOO-CV (PSIS)+ , LOOResult (..)+ , loo+ , chainLOO+ -- * Utilities+ , chainLogLikMatrix+ -- * LM / GLM posterior sampling (for WAIC / LOO-CV)+ , lmPosteriorLogLiks+ , glmPosteriorLogLiks+ , lmePosteriorLogLiks+ -- * Model-comparison weights (PyMC @pm.compare@ analogue)+ , CompareEntry (..)+ , CompareResult (..)+ , compareModels+ ) where++import Control.Monad (replicateM)+import Data.List (sort, transpose)+import qualified Numeric.LinearAlgebra as LA+import qualified Data.Vector.Storable as VS+import qualified Data.Vector.Algorithms.Intro as VAI+import System.Random.MWC (GenIO)+import System.Random.MWC.Distributions (normal)++import Hanalyze.Model.Core (FitResult (..), coefficientsV, residualsV)+import Hanalyze.Model.GLM (Family (..), LinkFn (..))+import Hanalyze.Model.HBM (ModelP, perObsLogLiks)+import Hanalyze.MCMC.Core (Chain, chainSamples)+import qualified Hanalyze.Stat.Distribution as Dist++-- ---------------------------------------------------------------------------+-- 結果型+-- ---------------------------------------------------------------------------++-- | WAIC result.+data WAICResult = WAICResult+ { waicValue :: Double -- ^ @WAIC = −2(lppd − p_waic)@; smaller is better.+ , waicLppd :: Double -- ^ Log pointwise predictive density.+ , waicPwaic :: Double -- ^ Effective number of parameters @p_waic@.+ , waicSE :: Double -- ^ Estimated standard error of @WAIC@.+ } deriving (Show)++-- | PSIS-LOO result.+data LOOResult = LOOResult+ { looValue :: Double -- ^ @−2 × elpd_loo@; smaller is better.+ , looElpd :: Double -- ^ @Σᵢ elpd_i@ (expected log predictive density).+ , looSE :: Double -- ^ Standard error of @looValue@.+ , looKHat :: [Double] -- ^ Per-observation Pareto @k̂@; @< 0.5@ good,+ -- @0.5–0.7@ acceptable, @> 0.7@ flag.+ , looKHatBad :: Int -- ^ Number of observations with @k̂ > 0.7@.+ } deriving (Show)++-- ---------------------------------------------------------------------------+-- WAIC+-- ---------------------------------------------------------------------------++-- | Compute WAIC from a log-likelihood matrix.+--+-- @logLikMat !! s !! i = log p(y_i | θ^s)@: rows are @S@ posterior+-- samples, columns are @N@ observations.+--+-- Internally builds an @S × N@ hmatrix matrix once and computes the+-- per-column @logSumExp@ and sample variance via Storable-Vector+-- folds. Replaces the previous @transpose [[Double]] + map@+-- formulation, which allocated @S × N@ list cells just to flip the+-- shape.+waic :: [[Double]] -> WAICResult+waic [] = WAICResult 0 0 0 0+waic logLikMat =+ let mat = LA.fromLists logLikMat -- S × N+ sN = LA.rows mat+ s = fromIntegral sN :: Double+ cols = LA.toColumns mat -- N storable vectors of length S+ n = length cols++ lppd_i = map (\c -> logSumExpVS c - log s) cols+ lppd = sum lppd_i+ pwaic_i = map sampleVarVS cols+ pwaic = sum pwaic_i+ waicVal = -2 * (lppd - pwaic)++ contrib = zipWith (\l p -> -2 * (l - p)) lppd_i pwaic_i+ se = sqrt (fromIntegral n * sampleVar contrib)++ in WAICResult waicVal lppd pwaic se+ -- Note: tested 'LA.tr mat + LA.toRows' to get contiguous Storable+ -- slices for per-row (= per-observation) folds, but the transpose+ -- allocation outweighed the cache benefit at @S=1000, N=200@. The+ -- 'toColumns' path stays ~12 ms; transpose path measured ~13.4 ms.+ -- arviz's @az.waic@ at 6.3 ms benefits from numpy axis-reductions+ -- and SIMD @exp@ that we cannot match without FFI.++-- | logSumExp over a Storable Vector. @m + log Σ exp(x - m)@ for+-- numerical stability.+logSumExpVS :: LA.Vector Double -> Double+logSumExpVS v+ | VS.null v = -1/0+ | otherwise =+ let m = VS.maximum v+ in m + log (VS.sum (VS.map (\x -> exp (x - m)) v))++-- | Sample variance (divisor @n - 1@) over a Storable Vector.+sampleVarVS :: LA.Vector Double -> Double+sampleVarVS v+ | VS.length v < 2 = 0+ | otherwise =+ let nD = fromIntegral (VS.length v) :: Double+ mu = VS.sum v / nD+ ss = VS.sum (VS.map (\x -> (x - mu) * (x - mu)) v)+ in ss / (nD - 1)++-- ---------------------------------------------------------------------------+-- LOO-CV (PSIS)+-- ---------------------------------------------------------------------------++-- | Compute PSIS-LOO from a log-likelihood matrix.+--+-- For each observation, importance weights are smoothed by a Pareto+-- distribution; this returns the truncated-IS LOO estimate together with+-- the diagnostic Pareto @k̂@.+loo :: [[Double]] -> LOOResult+loo [] = LOOResult 0 0 0 [] 0+loo logLikMat =+ -- Mirrors 'waic': @S × N@ hmatrix matrix once, then per-column+ -- 'psisElpdV' on Storable Vectors. Avoids the @transpose [[Double]]@+ -- (S × N list-cell allocation) and the per-column list ops in the+ -- old 'psisElpd'.+ let mat = LA.fromLists logLikMat -- S × N+ s = LA.rows mat+ cols = LA.toColumns mat+ n = length cols+ results = map (psisElpdV s) cols+ elpd_i = map fst results+ khat_i = map snd results+ elpd = sum elpd_i+ looVal = -2 * elpd+ se = sqrt (fromIntegral n * sampleVar elpd_i)+ nBad = length (filter (> 0.7) khat_i)+ in LOOResult looVal elpd se khat_i nBad++-- | PSIS estimate for a single observation: @(elpd_i, k̂_i)@.+--+-- Algorithm:+--+-- 1. Compute log importance weights @log r_i^s = −log p(y_i|θ^s)@.+-- 2. Fit a Pareto @k̂@ to the top @M = min(S/5, 3√S)@ values.+-- 3. Truncate weights at @log √S@ and renormalize for stability.+-- 4. @elpd_i = logSumExp(log W_s + log p(y_i|θ^s))@.+psisElpd :: Int -> [Double] -> (Double, Double)+psisElpd s colLL = psisElpdV s (VS.fromList colLL)++-- | Storable-Vector version of 'psisElpd'. Internal hot path used by+-- 'loo'. All steps stay on @VS.Vector Double@: no @[Double]@+-- intermediates, sort via 'Data.Vector.Algorithms.Intro' on a+-- mutable Storable buffer.+psisElpdV :: Int -> VS.Vector Double -> (Double, Double)+psisElpdV s colLL =+ let logR = VS.map negate colLL+ m = max 5 (min (s `div` 5)+ (floor (3 * sqrt (fromIntegral s :: Double))))+ sortedLogR = VS.modify VAI.sort logR -- ascending+ topM = VS.drop (s - m) sortedLogR+ khat = paretoKhatV topM++ logCap = 0.5 * log (fromIntegral s :: Double)+ capped = VS.map (min logCap) logR+ logZ = logSumExpVS capped+ logW = VS.map (\r -> r - logZ) capped++ elpdi = logSumExpVS (VS.zipWith (+) logW colLL)+ in (elpdi, khat)++-- | Estimate the Pareto shape @k̂@ from the top-@M@ log-weights+-- (ascending).+--+-- Uses the Hosking-Wallis (1987) moment estimator:+--+-- @+-- excess = exp(r − u) − 1 (u = lower threshold)+-- k̂ = 0.5 × (1 − μ² / s²) where μ = mean excess, s² = Var excess+-- @+paretoKhat :: [Double] -> Double+paretoKhat topM = paretoKhatV (VS.fromList topM)++-- | Storable-Vector version of 'paretoKhat'.+paretoKhatV :: VS.Vector Double -> Double+paretoKhatV topM+ | VS.length topM < 5 = 0+ | otherwise =+ let u = topM VS.! 0+ excess = VS.map (\r -> exp (r - u) - 1) topM+ mu = VS.sum excess / fromIntegral (VS.length excess)+ var = sampleVarVS excess+ in if var <= 0 || mu <= 0 then 0+ else 0.5 * (1 - mu ^ (2 :: Int) / var)++-- ---------------------------------------------------------------------------+-- Chain との連携+-- ---------------------------------------------------------------------------++-- | Build a log-likelihood matrix from a model and a chain.+-- Rows are post-burnin samples, columns are observations.+chainLogLikMatrix :: ModelP r -> Chain -> [[Double]]+chainLogLikMatrix model chain = map (perObsLogLiks model) (chainSamples chain)++-- | Compute WAIC directly from a model and chain.+chainWAIC :: ModelP r -> Chain -> WAICResult+chainWAIC model = waic . chainLogLikMatrix model++-- | Compute PSIS-LOO directly from a model and chain.+chainLOO :: ModelP r -> Chain -> LOOResult+chainLOO model = loo . chainLogLikMatrix model++-- ---------------------------------------------------------------------------+-- LM / GLM 事後サンプリング (WAIC/LOO-CV 用)+-- ---------------------------------------------------------------------------++-- | Generate an @S × N@ log-likelihood matrix from a flat-prior LM+-- posterior.+--+-- Sampling scheme:+--+-- @+-- σ² ~ InvGamma((n−p)/2, RSS/2) (drawn as RSS / χ²_{n-p})+-- β ~ MVN(β̂, σ² (X'X)⁻¹)+-- log p(y_i | β^s, σ^s) = log N(y_i; x_i·β^s, σ^s)+-- @+lmPosteriorLogLiks+ :: LA.Matrix Double -- ^ Design matrix @X@ (@n×p@).+ -> LA.Vector Double -- ^ Response @y@ (length @n@).+ -> FitResult -- ^ OLS fit result.+ -> Int -- ^ Number of posterior samples @S@.+ -> GenIO+ -> IO [[Double]]+lmPosteriorLogLiks x y fr s gen = do+ let n = LA.rows x+ p = LA.cols x+ df' = n - p+ beta0 = coefficientsV fr+ rss = let resV = residualsV fr in LA.dot resV resV+ xtxInv = LA.inv (LA.tr x LA.<> x)+ rChol = LA.chol (LA.trustSym xtxInv)+ lChol = LA.tr rChol+ replicateM s $ do+ chi2Vals <- replicateM df' (normal 0 1 gen)+ let chi2 = sum (map (^(2::Int)) chi2Vals)+ sigma = sqrt (rss / chi2)+ zVec <- fmap LA.fromList (replicateM p (normal 0 1 gen))+ let betaSamp = beta0 + LA.scale sigma (lChol LA.#> zVec)+ yHat = x LA.#> betaSamp+ -- Phase 12c: VS.zipWith fuses on Storable Vectors and avoids the+ -- two LA.toList allocations + Haskell list zip (cf. Phase 11c+ -- glmLogLik change).+ return (VS.toList (VS.zipWith (\yi yhi -> logNormDensity yi yhi sigma)+ y yHat))++-- | Generate an @S × N@ log-likelihood matrix from a Laplace-approximate+-- GLM posterior. For Gaussian-family models prefer 'lmPosteriorLogLiks'.+--+-- @+-- β ~ MVN(β̂, Fisher⁻¹)+-- log p(y_i | β^s) = family-specific log-density+-- @+glmPosteriorLogLiks+ :: Family+ -> LinkFn+ -> LA.Matrix Double -- ^ Design matrix @X@.+ -> LA.Vector Double -- ^ Response @y@.+ -> LA.Matrix Double -- ^ Inverse Fisher information.+ -> FitResult+ -> Int -- ^ Number of posterior samples @S@.+ -> GenIO+ -> IO [[Double]]+glmPosteriorLogLiks family linkFn x y fisherInv fr s gen = do+ let p = LA.rows fisherInv+ beta0 = coefficientsV fr+ rChol = LA.chol (LA.trustSym fisherInv)+ lChol = LA.tr rChol+ replicateM s $ do+ zVec <- fmap LA.fromList (replicateM p (normal 0 1 gen))+ let betaSamp = beta0 + lChol LA.#> zVec+ eta = x LA.#> betaSamp+ -- Phase 12c: same VS.zipWith / no toList pattern as+ -- 'lmPosteriorLogLiks'.+ return (VS.toList (VS.zipWith (glmLogDensity family linkFn) y eta))++-- | Log-likelihood matrix for the __conditional__ WAIC of a Gaussian+-- LME (random intercepts).+--+-- This is not a fully marginal GLMM posterior. It conditions on a point+-- estimate of the BLUPs @û@ and posterior-samples @(β, σ²)@ as if from+-- a residualized LM:+--+-- - @y' := y − Z·û@ (response with BLUP offset removed).+-- - @σ² ~ InvGamma((n−p)/2, RSS_cond/2)@ where @RSS_cond@ is the LME+-- conditional residual sum of squares.+-- - @β ~ MVN(β̂, σ² (X'X)⁻¹)@.+-- - @log p(y_i | β^s, û_{j(i)}, σ^s) = log N(y_i; X_iβ^s + û_{j(i)}, σ^s)@.+--+-- Because @u@ is held fixed, @p_WAIC@ tends to be smaller than the true+-- value; this is still useful for comparing fixed-effect structures on+-- the same data (see Gelman, Hwang & Vehtari 2014, §3.3).+lmePosteriorLogLiks+ :: LA.Matrix Double -- ^ Fixed-effect design matrix @X@ (@n×p@).+ -> LA.Vector Double -- ^ Response @y@ (length @n@).+ -> [Double] -- ^ Per-observation BLUP offset @û_{j(i)}@ (length @n@).+ -> FitResult -- ^ Fixed-effect LME fit result.+ -> Int -- ^ Number of posterior samples @S@.+ -> GenIO+ -> IO [[Double]]+lmePosteriorLogLiks x y offsets fr s gen = do+ let n = LA.rows x+ p = LA.cols x+ df' = n - p+ beta0 = coefficientsV fr+ rss = let resV = residualsV fr in LA.dot resV resV+ xtxInv = LA.inv (LA.tr x LA.<> x)+ rChol = LA.chol (LA.trustSym xtxInv)+ lChol = LA.tr rChol+ replicateM s $ do+ chi2Vals <- replicateM df' (normal 0 1 gen)+ let chi2 = sum (map (^(2::Int)) chi2Vals)+ sigSamp = sqrt (rss / chi2)+ zVec <- fmap LA.fromList (replicateM p (normal 0 1 gen))+ let betaSamp = beta0 + LA.scale sigSamp (lChol LA.#> zVec)+ yFix = LA.toList (x LA.#> betaSamp)+ yCond = zipWith (+) yFix offsets+ ys = LA.toList y+ return [ logNormDensity yi yhi sigSamp | (yi, yhi) <- zip ys yCond ]++logNormDensity :: Double -> Double -> Double -> Double+logNormDensity y mu sig+ | sig <= 0 = -1/0+ | otherwise = let d = (y - mu) / sig+ in -0.5 * log (2 * pi) - log sig - 0.5 * d * d++glmLogDensity :: Family -> LinkFn -> Double -> Double -> Double+glmLogDensity family linkFn y eta =+ let mu = case linkFn of+ Identity -> eta+ Log -> exp eta+ Logit -> 1 / (1 + exp (-eta))+ Sqrt -> eta * eta+ in case family of+ Gaussian -> logNormDensity y mu 1.0+ Poisson -> Dist.logDensity (Dist.Poisson (max 1e-10 mu)) y+ Binomial -> Dist.logDensity (Dist.Binomial 1 (max 1e-8 (min (1-1e-8) mu))) y++-- ---------------------------------------------------------------------------+-- 数値ユーティリティ+-- ---------------------------------------------------------------------------++logSumExp :: [Double] -> Double+logSumExp [] = -1/0+logSumExp xs =+ let m = maximum xs+ in m + log (sum (map (\x -> exp (x - m)) xs))++mean :: [Double] -> Double+mean [] = 0+mean xs = sum xs / fromIntegral (length xs)++-- | [日本語]: 標本分散 (n-1 で割る)+-- [English]: Sample variance (divides by n-1).+sampleVar :: [Double] -> Double+sampleVar xs+ | length xs < 2 = 0+ | otherwise =+ let mu = mean xs+ in sum (map (\x -> (x - mu) ^ (2::Int)) xs)+ / fromIntegral (length xs - 1)++-- ---------------------------------------------------------------------------+-- モデル比較の重み (Pseudo-BMA, ArviZ.compare 相当)+-- ---------------------------------------------------------------------------++-- | One candidate model for comparison: label and log-likelihood matrix.+data CompareEntry = CompareEntry+ { ceLabel :: String -- ^ Model label.+ , ceLogLikMat :: [[Double]] -- ^ @S × N@ log-likelihood matrix.+ } deriving (Show)++-- | Per-model comparison result.+data CompareResult = CompareResult+ { crLabel :: String -- ^ Model label.+ , crWAIC :: Double -- ^ WAIC (smaller is better).+ , crLOO :: Double -- ^ LOO (smaller is better).+ , crDeltaWAIC :: Double -- ^ @ΔWAIC@ vs the best model.+ , crDeltaLOO :: Double -- ^ @ΔLOO@ vs the best model.+ , crSE :: Double -- ^ Standard error of @WAIC@.+ , crKHatBad :: Int -- ^ Number of observations with @k̂ > 0.7@.+ , crWeight :: Double -- ^ Pseudo-BMA weight (sums to 1 over models).+ } deriving (Show)++-- | [日本語]: 複数モデルを WAIC / LOO で比較し、 Pseudo-BMA 重みを計算する。+--+-- アルゴリズム:+--+-- - 各モデルの WAIC と LOO を計算する。+-- - 最良 (最小) のモデルを @ΔWAIC@ / @ΔLOO@ の基準とする。+-- - Pseudo-BMA 重み: @w_i = exp(elpd_i) / Σ exp(elpd_j)@。+-- (実用的には Δ から計算: w_i ∝ exp(-Δelpd_i))+--+-- [English]: Compare several models by WAIC / LOO and compute+-- Pseudo-BMA weights.+--+-- Algorithm:+--+-- - Compute WAIC and LOO for each model.+-- - Use the best (minimum) model as baseline for @ΔWAIC@ / @ΔLOO@.+-- - Pseudo-BMA weight: @w_i = exp(elpd_i) / Σ exp(elpd_j)@.+-- (in practice, computed from Δ: w_i ∝ exp(-Δelpd_i))+compareModels :: [CompareEntry] -> [CompareResult]+compareModels entries =+ let waicResults = map (\e -> (ceLabel e, waic (ceLogLikMat e))) entries+ looResults = map (\e -> (ceLabel e, loo (ceLogLikMat e))) entries+ waicVals = map (waicValue . snd) waicResults+ looVals = map (looValue . snd) looResults+ -- elpd_loo (= -looValue / 2) 基準で Pseudo-BMA 重みを計算+ elpds = map (\v -> -v / 2) looVals+ maxElpd = maximum elpds+ unnorm = map (\e -> exp (e - maxElpd)) elpds+ total = sum unnorm+ weights = map (/ total) unnorm+ bestWaic = minimum waicVals+ bestLoo = minimum looVals+ in zipWith4 mkRow entries waicResults looResults weights+ where+ mkRow entry (lbl, w) (_, l) wt = CompareResult+ { crLabel = lbl+ , crWAIC = waicValue w+ , crLOO = looValue l+ , crDeltaWAIC = waicValue w - minimum (map (\e -> waicValue (waic (ceLogLikMat e))) entries)+ , crDeltaLOO = looValue l - minimum (map (\e -> looValue (loo (ceLogLikMat e))) entries)+ , crSE = waicSE w+ , crKHatBad = looKHatBad l+ , crWeight = wt+ }+ zipWith4 f as bs cs ds = case (as, bs, cs, ds) of+ (a:as', b:bs', c:cs', d:ds') -> f a b c d : zipWith4 f as' bs' cs' ds'+ _ -> []