packages feed

hanalyze-0.2.0.0: demo/doe/RSMSampleSizeDemo.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE PartialTypeSignatures #-}
{-# OPTIONS_GHC -Wno-partial-type-signatures #-}
-- | DOE demo: RSM の予測精度 vs 実験数 (= 必要実験数の見極め)。
--
-- 温度 (3 水準・Num)・エネルギー (3 水準・Num)・角度 (連続・Cont) の
-- 二次応答曲面を対象に:
--   1. @customDesign@ (二次モデル) で n-run の D-最適設計を作る
--   2. 既知の真の曲面 + Gaussian noise で応答を sim
--   3. 二次 OLS を fit
--   4. 独立テスト集合で 真の曲面 vs 予測 の RMSE を測る (設計シード × noise 反復で平均)
--   5. n を振り、 hgg で「RMSE vs n」の折れ線 (noise sd 参照線つき) を SVG 出力
--
-- 真の曲面には二次モデルで表せない @0.8·angle³@ を混ぜてあり、 n を増やしても
-- 消えない bias 床を作る (= 実務の「これ以上は実験でなくモデルを増やせ」を再現)。
module Main where

import qualified Data.Vector as V
import qualified Numeric.LinearAlgebra as LA
import qualified Data.Text as T
import           Data.Text (Text)
import           Text.Printf (printf)
import           System.Directory (createDirectoryIfMissing)

import           Hanalyze.Plot
                   ( customDesign, customSpec, numFactor, contFactor, quadratic, designFrame
                   , designTable, designModel, profiler, toPlot, (|->) )
import           Hanalyze.DataIO.Convert (getDoubleVec)
import           Hgg.Plot.Spec
                   ( ColData (..), layer, linePoints, scatterPoints, Point2 (..)
                   , color, markWidth
                   , refHorizontal, title, subtitle, xLabel, yLabel, width, height
                   , theme, ThemeName (..) )
import           Hgg.Plot.Frame ((|>>))
import           Hgg.Plot.Color (fromHex)
import           Hgg.Plot.Backend.SVG (saveSVG, saveSVGBound)

-- === 真の応答曲面とモデル ==================================================

-- 正規化座標 (各 ~[-1,1])。
ut, ue, ua :: Double -> Double
ut t = (t - 165) / 15
ue e = (e - 20) / 10
ua a = (a - 45) / 45

-- 真の曲面 = 二次 + 0.8·angle³ (二次モデルでは捉えられない bias 源)。
gTrue :: Double -> Double -> Double -> Double
gTrue t e a =
  let x = ut t; y = ue e; z = ua a
  in 50 + 8*x + 5*y + 6*z - 4*x*x - 3*y*y - 5*z*z + 2*x*y + 1.5*x*z - 1*y*z
       + 0.8*z*z*z

-- 二次モデルの特徴ベクトル (10 項: 切片 + 主 3 + 二乗 3 + 交互 3)。
feat :: Double -> Double -> Double -> [Double]
feat t e a = let x = ut t; y = ue e; z = ua a
             in [1, x, y, z, x*x, y*y, z*z, x*y, x*z, y*z]

noiseSd :: Double
noiseSd = 1.5

-- === 決定的 PRNG → Gaussian (Box-Muller) ==================================

lcg :: Int -> Int
lcg s = (1103515245 * s + 12345) `mod` 2147483648

u01 :: Int -> Double
u01 s = fromIntegral s / 2147483648

gaussians :: Int -> [Double]
gaussians seed = go (lcg seed)
  where
    go s = let s1 = lcg s; s2 = lcg s1
               u1 = max 1e-12 (u01 s1); u2 = u01 s2
           in sqrt (-2 * log u1) * cos (2 * pi * u2) : go s2

-- === 設計・fit・評価 ======================================================

-- 温度/エネルギー = 3 水準 Num、 角度 = 連続。 二次モデルで D-最適設計。
design n dseed = customDesign (customSpec
  [ numFactor "temp"   [150, 165, 180]
  , numFactor "energy" [10, 20, 30]
  , contFactor "angle" (0, 90) ]
  (quadratic ["temp", "energy", "angle"]) n dseed)

-- 設計 (designFrame) から (temp, energy, angle) 実値の行を取り出す。
rowsOf :: Int -> Int -> [(Double, Double, Double)]
rowsOf n dseed =
  let df = designFrame (design n dseed)
      col :: Text -> [Double]
      col c = maybe (error (T.unpack c)) V.toList (getDoubleVec c df)
  in zip3 (col "temp") (col "energy") (col "angle")

-- 固定・独立なテスト集合 (2000 点): 離散 temp/energy + 連続 angle。
testSet :: [(Double, Double, Double)]
testSet = take 2000
  [ ([150,165,180] !! i, [10,20,30] !! j, 90 * u01 (lcg (lcg (7919*k + 13))))
  | (i, j, k) <- zip3 (cycle [0,1,2,1,0,2,2,0,1]) (cycle [1,2,0,2,1,0,1,2,0]) [1 ..] ]

-- OLS: beta = pinv(X) y。
ols :: [[Double]] -> [Double] -> [Double]
ols xs ys = LA.toList (LA.flatten (LA.pinv (LA.fromLists xs) LA.<> LA.asColumn (LA.fromList ys)))

-- 1 fit (設計シード dseed・noise 反復 rep) の テスト RMSE。
rmseFor :: Int -> Int -> Int -> Double
rmseFor n dseed rep =
  let rws  = rowsOf n dseed
      xs   = [ feat t e a | (t, e, a) <- rws ]
      ys   = [ gTrue t e a + noiseSd * z
             | ((t, e, a), z) <- zip rws (gaussians (n*1000 + dseed*37 + rep)) ]
      beta = ols xs ys
      errs = [ sum (zipWith (*) (feat t e a) beta) - gTrue t e a | (t, e, a) <- testSet ]
  in sqrt (sum (map (^ (2 :: Int)) errs) / fromIntegral (length errs))

-- 各 n を 6 設計シード × 6 noise 反復 = 36 fit で平均。
meanRmse :: Int -> Double
meanRmse n = sum [ rmseFor n ds rp | ds <- [1..6], rp <- [1..6] ] / 36

-- === 効果プロット (CI 帯) : n による信頼区間の変化 =======================

-- 応答 y を design frame に載せて二次モデルを当てはめ、 profiler 用モデルを返す。
--   @(("y", ys) : designTable plan) |-> designModel plan "y"@ が慣用形。
--   各 n は別々の設計・データ → 別々のモデル (データはモデル内に同梱される)。
modelN :: Int -> Int -> _
modelN n dseed =
  let plan   = design n dseed
      rs     = designTable plan
      colD :: Text -> [Double]
      colD c = maybe (error (T.unpack c)) id (lookup c rs)
      ts = colD "temp"; es = colD "energy"; as = colD "angle"
      ys = [ gTrue t e a + noiseSd * z
           | (t, e, a, z) <- zip4 ts es as (gaussians (n*1000 + 1)) ]
  in (("y", ys) : rs) |-> designModel plan "y"
  where
    zip4 (a:as') (b:bs) (c:cs) (d:ds) = (a, b, c, d) : zip4 as' bs cs ds
    zip4 _ _ _ _                      = []

-- 効果プロット図: 横 = 因子・縦 = 応答、 1 行 3 列 (因子) を n ごとに縦積み。
-- **profiler** が予測線 + 95% CI 帯 + 実測散布を (応答×因子) で自動描画する (JMP 流)。
-- 行 = models リスト (= n)、 列 = 因子。 各 n は別モデルで、 データはモデル内に同梱
-- されるため 束ねは空 df でよい。 n=10 は係数10個=飽和 (df=0) だが、 CI 帯計算の
-- df<=0 ガード (Phase 82・LM.hs) により例外を出さず帯が線に潰れる (= CI 不能を素直に表現)。
effectGridFigure :: IO ()
effectGridFigure = do
  let dseed = 20260708
      spec  = toPlot (profiler [ ("n=10", modelN 10 dseed)
                               , ("n=20", modelN 20 dseed)
                               , ("n=40", modelN 40 dseed) ]
                               ["temp", "energy", "angle"])
                <> title "RSM 効果プロット: n で信頼区間がどう変わるか (行=n・列=因子)"
                <> width 780 <> height 720       -- 3×3 grid (意図的な非既定サイズ)
                <> theme ThemeGrey
      noDf = [] :: [(Text, ColData)]             -- profiler のデータはモデル内・束ねは空
  createDirectoryIfMissing True "demo-output/doe"
  saveSVGBound "demo-output/doe/rsm-effects-by-n.svg" (noDf |>> spec)
  putStrLn "  → wrote demo-output/doe/rsm-effects-by-n.svg"

-- === main ================================================================

main :: IO ()
main = do
  putStrLn "═══════════════════════════════════════════════════════════════"
  putStrLn "  RSM 予測精度 vs 実験数 (温度3水準 × エネルギー3水準 × 角度連続)"
  putStrLn "═══════════════════════════════════════════════════════════════"
  printf "  真の曲面 = 二次 + 0.8·angle³ (bias 源), noise sd = %.2f\n" noiseSd
  printf "  各 n を 6 設計シード × 6 noise 反復 = 36 fit で平均、 テスト %d 点\n\n"
         (length testSet)
  let ns    = [10, 12, 14, 16, 20, 24, 30, 40, 60, 80]
      rmses = [ (n, meanRmse n) | n <- ns ]
  printf "  %4s | %8s | %8s | %s\n" ("n" :: String) ("RMSE" :: String)
         ("noise比" :: String) ("前点比 改善%" :: String)
  putStrLn "  -----|----------|----------|-----------"
  mapM_ (\((n, r), prev) ->
           let imp = case prev of
                       Nothing         -> ""
                       Just (_, pr)    -> printf "%.1f%%" (100 * (pr - r) / pr) :: String
           in printf "  %4d | %8.4f | %8.2f | %s\n" n r (r / noiseSd) imp)
        (zip rmses (Nothing : map Just rmses))
  putStrLn ""

  -- === hgg で RMSE vs n を描画 ===
  createDirectoryIfMissing True "demo-output/doe"
  let pts   = [ Point2 (fromIntegral n) r | (n, r) <- rmses ]
      curve = fromHex "#2c7fb8"
      spec  = layer (linePoints pts <> color curve <> markWidth 2.5)
           <> layer (scatterPoints pts <> color curve)
           <> refHorizontal noiseSd                     -- noise sd の水平参照線
           <> title    "RSM 予測精度 vs 実験数"
           <> subtitle "参照線 = noise sd (1 測定の誤差)。 下回れば曲面が生データより正確"
           <> xLabel   "実験数 n"
           <> yLabel   "予測 RMSE (真の曲面 vs 予測)"
           -- サイズは指定せず既定 (468×288pt = 624×384px・README 図と同寸) に合わせる
           <> theme ThemeGrey
  saveSVG "demo-output/doe/rsm-samplesize.svg" spec
  putStrLn "  → wrote demo-output/doe/rsm-samplesize.svg"

  -- === 効果プロット (n=10/20/40 で CI 帯がどう変わるか) ===
  putStrLn ""
  putStrLn "  効果プロット (行=n · 列=因子・CI 帯) を生成..."
  effectGridFigure