VBAF.ML.Explainability.ps1

#Requires -Version 5.1
<#
.SYNOPSIS
    Model Explainability - SHAP, LIME, PDP, Feature Importance
.DESCRIPTION
    Implements model explainability from scratch.
    Designed as a TEACHING resource - every step explained.
    Features included:
      - SHAP values : Shapley values for feature attribution
      - Feature importance : permutation importance (model-agnostic)
      - Partial dependence : how predictions change with one feature
      - LIME explanations : local linear approximations
      - Model-agnostic : works with ANY VBAF model via Predict()
    All methods only call model.Predict() - no access to internals needed!
.NOTES
    Part of VBAF - Phase 7 Production Features - v2.1.0
    PS 5.1 compatible
    Teaching project - XAI concepts explained step by step!
#>


# ============================================================
# TEACHING NOTE: Why explainability?
# "A model that cannot be explained cannot be trusted."
#
# BLACK BOX problem: complex models (Random Forest, Neural Net)
# give great predictions but we don't know WHY.
#
# EXPLAINABILITY answers:
# "Why did the model predict £245k for this house?"
# -> size_sqm contributed +£80k
# -> bedrooms contributed +£20k
# -> age_years contributed -£15k
#
# Three levels of explanation:
# GLOBAL : which features matter most overall?
# LOCAL : why this specific prediction?
# WHAT-IF : how does changing one feature affect predictions?
# ============================================================

# ============================================================
# PERMUTATION FEATURE IMPORTANCE (global)
# ============================================================
# TEACHING NOTE: Permutation importance answers:
# "If I randomly shuffle feature X, how much does accuracy drop?"
#
# Algorithm:
# 1. Compute baseline score on all data
# 2. For each feature:
# a. Shuffle that feature randomly
# b. Recompute score with shuffled data
# c. Importance = baseline - shuffled score
# Large drop = feature was important!
# Small drop = feature was not being used much
#
# Key advantage: works with ANY model!
# Key disadvantage: correlated features share importance.
# ============================================================

function Get-VBAFPermutationImportance {
    param(
        [object]     $Model,
        [string]     $ModelType,
        [double[][]] $X,
        [double[]]   $y,
        [string[]]   $FeatureNames = @(),
        [int]        $NRepeats     = 10,
        [string]     $Metric       = "R2",
        [int]        $Seed         = 42
    )

    $rng       = [System.Random]::new($Seed)
    $nFeatures = $X[0].Length
    $n         = $X.Length

    if ($FeatureNames.Length -eq 0) {
        $FeatureNames = 0..($nFeatures-1) | ForEach-Object { "feature$_" }
    }

    # Baseline score
    $baseline = Get-VBAFScore -Model $Model -X $X -y $y -Metric $Metric

    Write-Host ""
    Write-Host "🔀 Permutation Feature Importance" -ForegroundColor Green
    Write-Host (" Model : {0}" -f $ModelType)   -ForegroundColor Cyan
    Write-Host (" Baseline: {0}={1:F4}" -f $Metric, $baseline) -ForegroundColor Cyan
    Write-Host (" Repeats : {0}" -f $NRepeats)    -ForegroundColor Cyan
    Write-Host ""
    Write-Host (" {0,-15} {1,10} {2,10} {3}" -f "Feature", "Importance", "Std", "Bar") -ForegroundColor Yellow
    Write-Host (" {0}" -f ("-" * 55)) -ForegroundColor DarkGray

    $results = @()
    for ($f = 0; $f -lt $nFeatures; $f++) {
        $scores = @()
        for ($r = 0; $r -lt $NRepeats; $r++) {
            # Shuffle feature f
            $Xshuffled = @()
            $colVals   = @($X | ForEach-Object { $_[$f] })
            # Fisher-Yates shuffle
            for ($i = $colVals.Length - 1; $i -gt 0; $i--) {
                $j = $rng.Next(0, $i + 1)
                $tmp = $colVals[$i]; $colVals[$i] = $colVals[$j]; $colVals[$j] = $tmp
            }
            for ($i = 0; $i -lt $n; $i++) {
                $row = [double[]]$X[$i].Clone()
                $row[$f] = $colVals[$i]
                $Xshuffled += ,$row
            }
            $scores += Get-VBAFScore -Model $Model -X $Xshuffled -y $y -Metric $Metric
        }
        $avgScore  = ($scores | Measure-Object -Average).Average
        $importance= [Math]::Round($baseline - $avgScore, 4)
        $mean      = ($scores | Measure-Object -Average).Average
        $std       = [Math]::Round([Math]::Sqrt((($scores | ForEach-Object { ($_ - $mean)*($_ - $mean) } | Measure-Object -Sum).Sum / $scores.Count)), 4)
        $bar       = "█" * [int]([Math]::Max(0, $importance * 30))
        $color     = if ($importance -gt 0.05) { "Green" } elseif ($importance -gt 0) { "Yellow" } else { "DarkGray" }
        Write-Host (" {0,-15} {1,10:F4} {2,10:F4} {3}" -f $FeatureNames[$f], $importance, $std, $bar) -ForegroundColor $color
        $results += @{ Feature=$FeatureNames[$f]; Importance=$importance; Std=$std; Index=$f }
    }

    $sorted = $results | Sort-Object { $_.Importance } -Descending
    Write-Host ""
    Write-Host (" Most important: {0} (Δ{1}={2:F4})" -f $sorted[0].Feature, $Metric, $sorted[0].Importance) -ForegroundColor Green
    Write-Host ""
    return $results
}

# ============================================================
# SHAP VALUES (local + global)
# ============================================================
# TEACHING NOTE: SHAP = SHapley Additive exPlanations
# Based on game theory! Imagine features are "players" in a game
# where the "payout" is the model prediction.
# SHAP fairly distributes the payout among players.
#
# Shapley value formula (simplified):
# For each feature, average its marginal contribution
# across ALL possible subsets of other features.
#
# Property: SHAP values always sum to:
# prediction - expected_prediction (baseline)
# This is the EFFICIENCY property - SHAP is fully faithful!
#
# Exact SHAP is exponential in features. We use sampling:
# - Sample random subsets
# - Measure marginal contribution of each feature
# - Average across samples
# ============================================================

function Get-VBAFSHAPValues {
    param(
        [object]     $Model,
        [double[]]   $Instance,       # single sample to explain
        [double[][]] $Background,     # reference distribution (training data)
        [string[]]   $FeatureNames = @(),
        [int]        $NSamples     = 50,
        [int]        $Seed         = 42
    )

    $rng       = [System.Random]::new($Seed)
    $nFeatures = $Instance.Length
    if ($FeatureNames.Length -eq 0) {
        $FeatureNames = 0..($nFeatures-1) | ForEach-Object { "f$_" }
    }

    # Expected prediction (baseline)
    $bgPreds  = @($Background | ForEach-Object { $Model.Predict(@(,[double[]]$_))[0] })
    $baseline = ($bgPreds | Measure-Object -Average).Average

    # Instance prediction
    $instancePred = $Model.Predict(@(,$Instance))[0]

    # Estimate Shapley values via sampling
    $shapValues = @(0.0) * $nFeatures

    for ($s = 0; $s -lt $NSamples; $s++) {
        # Pick random background sample
        $bgSample = [double[]]$Background[$rng.Next(0, $Background.Length)]

        # Random feature ordering
        $order = @(0..($nFeatures-1))
        for ($i = $order.Length - 1; $i -gt 0; $i--) {
            $j = $rng.Next(0, $i+1); $tmp = $order[$i]; $order[$i] = $order[$j]; $order[$j] = $tmp
        }

        # Walk through order, compute marginal contribution of each feature
        $current = [double[]]$bgSample.Clone()
        foreach ($f in $order) {
            $before      = $Model.Predict(@(,$current))[0]
            $current[$f] = $Instance[$f]
            $after       = $Model.Predict(@(,$current))[0]
            $shapValues[$f] += ($after - $before)
        }
    }

    # Average over samples
    for ($f = 0; $f -lt $nFeatures; $f++) { $shapValues[$f] /= $NSamples }

    return @{
        Values       = $shapValues
        Baseline     = $baseline
        Prediction   = $instancePred
        FeatureNames = $FeatureNames
        Instance     = $Instance
    }
}

function Show-VBAFSHAPExplanation {
    param([hashtable]$SHAP, [string]$ModelType = "")

    Write-Host ""
    Write-Host "🎯 SHAP Explanation" -ForegroundColor Green
    if ($ModelType -ne "") { Write-Host (" Model: {0}" -f $ModelType) -ForegroundColor Cyan }
    Write-Host (" Baseline prediction : {0:F4}" -f $SHAP.Baseline)   -ForegroundColor DarkGray
    Write-Host (" Actual prediction : {0:F4}" -f $SHAP.Prediction) -ForegroundColor White
    Write-Host (" SHAP sum : {0:F4}" -f ($SHAP.Values | Measure-Object -Sum).Sum) -ForegroundColor DarkGray
    Write-Host ""
    Write-Host (" {0,-15} {1,8} {2,10} {3}" -f "Feature", "Value", "SHAP", "Contribution") -ForegroundColor Yellow
    Write-Host (" {0}" -f ("-" * 60)) -ForegroundColor DarkGray

    $sorted = @()
    for ($f = 0; $f -lt $SHAP.Values.Length; $f++) {
        $sorted += @{ Name=$SHAP.FeatureNames[$f]; Value=$SHAP.Instance[$f]; SHAP=$SHAP.Values[$f] }
    }
    $sorted = $sorted | Sort-Object { [Math]::Abs($_.SHAP) } -Descending

    foreach ($s in $sorted) {
        $bar   = "█" * [int]([Math]::Min(20, [Math]::Abs($s.SHAP) * 5))
        $sign  = if ($s.SHAP -ge 0) { "+" } else { "-" }
        $color = if ($s.SHAP -gt 0) { "Green" } elseif ($s.SHAP -lt 0) { "Red" } else { "DarkGray" }
        Write-Host (" {0,-15} {1,8:F2} {2}{3,9:F4} {4}{5}" -f $s.Name, $s.Value, $sign, [Math]::Abs($s.SHAP), $bar, $sign) -ForegroundColor $color
    }
    Write-Host ""
}

# ============================================================
# PARTIAL DEPENDENCE PLOTS (PDP)
# ============================================================
# TEACHING NOTE: PDP answers "how does prediction change
# when I vary feature X, holding everything else constant?"
#
# Algorithm:
# For each value v in feature X's range:
# 1. Replace feature X with v for ALL samples
# 2. Compute mean prediction
# 3. Plot mean prediction vs v
#
# This marginalizes out all other features!
# Shows the pure effect of X on predictions.
# ============================================================

function Get-VBAFPartialDependence {
    param(
        [object]     $Model,
        [double[][]] $X,
        [int]        $FeatureIndex,
        [string]     $FeatureName  = "",
        [int]        $NGrid        = 20    # number of values to evaluate
    )

    $name    = if ($FeatureName -ne "") { $FeatureName } else { "feature$FeatureIndex" }
    $colVals = @($X | ForEach-Object { [double]$_[$FeatureIndex] })
    $minVal  = ($colVals | Measure-Object -Minimum).Minimum
    $maxVal  = ($colVals | Measure-Object -Maximum).Maximum
    $step    = ($maxVal - $minVal) / ($NGrid - 1)

    $gridVals = @()
    $pdpMeans = @()

    for ($g = 0; $g -lt $NGrid; $g++) {
        $val    = $minVal + $g * $step
        $gridVals += $val
        # Replace feature with val for all samples
        $Xmod   = @($X | ForEach-Object {
            $row = [double[]]$_.Clone()
            $row[$FeatureIndex] = $val
            ,$row
        })
        $preds    = @($Xmod | ForEach-Object { $Model.Predict(@(,[double[]]$_))[0] })
        $pdpMeans += ($preds | Measure-Object -Average).Average
    }

    # ASCII plot
    $minPDP = ($pdpMeans | Measure-Object -Minimum).Minimum
    $maxPDP = ($pdpMeans | Measure-Object -Maximum).Maximum
    $range  = [Math]::Max($maxPDP - $minPDP, 0.001)
    $height = 8

    Write-Host ""
    Write-Host ("📈 Partial Dependence: {0}" -f $name) -ForegroundColor Green
    Write-Host (" Range: [{0:F1}, {1:F1}] PDP: [{2:F2}, {3:F2}]" -f $minVal, $maxVal, $minPDP, $maxPDP) -ForegroundColor DarkGray
    Write-Host ""

    # Build grid
    $grid = @()
    for ($row = 0; $row -lt $height; $row++) { $grid += ,(@(" ") * $NGrid) }
    for ($g = 0; $g -lt $NGrid; $g++) {
        $row = $height - 1 - [int](($pdpMeans[$g] - $minPDP) / $range * ($height - 1))
        $row = [Math]::Max(0, [Math]::Min($height-1, $row))
        $grid[$row][$g] = "●"
    }

    $yLabels = @($maxPDP, ($maxPDP+$minPDP)/2, $minPDP)
    for ($row = 0; $row -lt $height; $row++) {
        $yLabel = if ($row -eq 0) { "{0,7:F1} " -f $maxPDP }
                  elseif ($row -eq [int]($height/2)) { "{0,7:F1} " -f (($maxPDP+$minPDP)/2) }
                  elseif ($row -eq $height-1) { "{0,7:F1} " -f $minPDP }
                  else { " " }
        Write-Host ("{0}│{1}" -f $yLabel, ($grid[$row] -join "")) -ForegroundColor Cyan
    }
    Write-Host (" └{0}" -f ("─" * $NGrid)) -ForegroundColor Cyan
    Write-Host (" {0,-10} {1,10}" -f ("{0:F1}" -f $minVal), ("{0:F1}" -f $maxVal)) -ForegroundColor DarkGray
    Write-Host (" {0}" -f $name) -ForegroundColor DarkGray
    Write-Host ""

    return @{ GridValues=$gridVals; PDPMeans=$pdpMeans; FeatureName=$name }
}

# ============================================================
# LIME EXPLANATIONS (local)
# ============================================================
# TEACHING NOTE: LIME = Local Interpretable Model-agnostic Explanations
# Key idea: even if the global model is complex and non-linear,
# it's approximately LINEAR in a small neighbourhood!
#
# Algorithm for one instance x:
# 1. Sample N perturbed versions of x (add small noise)
# 2. Get predictions from the black-box model for all perturbed samples
# 3. Weight samples by similarity to x (closer = higher weight)
# 4. Fit a SIMPLE LINEAR MODEL on the weighted samples
# 5. The linear model's coefficients = LIME explanation!
#
# The linear model is the explanation - easy to understand!
# ============================================================

function Get-VBAFLIMEExplanation {
    param(
        [object]     $Model,
        [double[]]   $Instance,
        [double[][]] $X,             # training data for feature statistics
        [string[]]   $FeatureNames = @(),
        [int]        $NSamples     = 100,
        [double]     $KernelWidth  = 0.75,
        [int]        $Seed         = 42
    )

    $rng       = [System.Random]::new($Seed)
    $nFeatures = $Instance.Length
    if ($FeatureNames.Length -eq 0) {
        $FeatureNames = 0..($nFeatures-1) | ForEach-Object { "f$_" }
    }

    # Feature statistics for perturbation
    $means = @(0.0) * $nFeatures
    $stds  = @(0.0) * $nFeatures
    for ($f = 0; $f -lt $nFeatures; $f++) {
        $vals     = @($X | ForEach-Object { [double]$_[$f] })
        $means[$f]= ($vals | Measure-Object -Average).Average
        $variance = ($vals | ForEach-Object { ($_ - $means[$f])*($_ - $means[$f]) } | Measure-Object -Sum).Sum / $vals.Count
        $stds[$f] = [Math]::Max(0.001, [Math]::Sqrt($variance))
    }

    # Generate perturbed samples
    $perturbedX    = @()
    $perturbedPred = @()
    $weights       = @()

    for ($s = 0; $s -lt $NSamples; $s++) {
        $sample = @(0.0) * $nFeatures
        for ($f = 0; $f -lt $nFeatures; $f++) {
            # Box-Muller Gaussian noise
            $u1       = [Math]::Max(1e-10, $rng.NextDouble())
            $u2       = $rng.NextDouble()
            $gauss    = [Math]::Sqrt(-2.0 * [Math]::Log($u1)) * [Math]::Cos(2.0 * [Math]::PI * $u2)
            $sample[$f] = $Instance[$f] + $gauss * $stds[$f] * 0.5
        }
        $pred = $Model.Predict(@(,[double[]]$sample))[0]
        $perturbedX    += ,$sample
        $perturbedPred += $pred

        # Kernel weight: similarity to instance
        $dist = 0.0
        for ($f = 0; $f -lt $nFeatures; $f++) {
            $d     = ($sample[$f] - $Instance[$f]) / $stds[$f]
            $dist += $d * $d
        }
        $weights += [Math]::Exp(-$dist / (2.0 * $KernelWidth * $KernelWidth))
    }

    # Fit weighted linear model (WLS: weighted least squares)
    # Normal equations: (X'WX)^-1 X'Wy (simplified for small feature sets)
    $coeffs = Get-VBAFWeightedLinearFit -X $perturbedX -y $perturbedPred -W $weights

    # Intercept = instance prediction for reference
    $instancePred = $Model.Predict(@(,$Instance))[0]

    Write-Host ""
    Write-Host "🍋 LIME Explanation" -ForegroundColor Green
    Write-Host (" Instance prediction: {0:F4}" -f $instancePred) -ForegroundColor White
    Write-Host (" Local model R2 : ~approx") -ForegroundColor DarkGray
    Write-Host ""
    Write-Host (" {0,-15} {1,8} {2,10} {3}" -f "Feature", "Value", "Coefficient", "Impact") -ForegroundColor Yellow
    Write-Host (" {0}" -f ("-" * 58)) -ForegroundColor DarkGray

    $sorted = @()
    for ($f = 0; $f -lt $nFeatures; $f++) {
        $impact = $coeffs[$f] * $Instance[$f]
        $sorted += @{ Name=$FeatureNames[$f]; Value=$Instance[$f]; Coeff=$coeffs[$f]; Impact=$impact }
    }
    $sorted = $sorted | Sort-Object { [Math]::Abs($_.Impact) } -Descending

    foreach ($s in $sorted) {
        $bar   = "█" * [int]([Math]::Min(15, [Math]::Abs($s.Impact) / [Math]::Max(0.001, [Math]::Abs($instancePred)) * 15))
        $sign  = if ($s.Impact -ge 0) { "+" } else { "-" }
        $color = if ($s.Impact -gt 0) { "Green" } elseif ($s.Impact -lt 0) { "Red" } else { "DarkGray" }
        Write-Host (" {0,-15} {1,8:F2} {2,10:F4} {3}{4}" -f $s.Name, $s.Value, $s.Coeff, $bar, $sign) -ForegroundColor $color
    }
    Write-Host ""

    return @{ Coefficients=$coeffs; FeatureNames=$FeatureNames; Instance=$Instance; Prediction=$instancePred }
}

# Weighted least squares helper
function Get-VBAFWeightedLinearFit {
    param([double[][]]$X, [double[]]$y, [double[]]$W)

    $n = $X.Length; $p = $X[0].Length
    # Gradient descent on weighted MSE (simple, PS 5.1 safe)
    $coeffs = @(0.0) * $p
    $lr     = 0.01
    $totalW = ($W | Measure-Object -Sum).Sum

    for ($iter = 0; $iter -lt 200; $iter++) {
        $grad = @(0.0) * $p
        for ($i = 0; $i -lt $n; $i++) {
            $xi   = [double[]]$X[$i]
            $pred = 0.0
            for ($f = 0; $f -lt $p; $f++) { $pred += $coeffs[$f] * $xi[$f] }
            $err  = $pred - $y[$i]
            for ($f = 0; $f -lt $p; $f++) {
                $grad[$f] += $W[$i] * $err * $xi[$f]
            }
        }
        for ($f = 0; $f -lt $p; $f++) {
            $coeffs[$f] = $coeffs[$f] - $lr * $grad[$f] / $totalW
        }
    }
    return $coeffs
}

# ============================================================
# SCORING HELPER (model-agnostic)
# ============================================================

function Get-VBAFScore {
    param([object]$Model, [double[][]]$X, [double[]]$y, [string]$Metric = "R2")

    $preds = @($X | ForEach-Object { $Model.Predict(@(,[double[]]$_))[0] })
    $mean  = ($y | Measure-Object -Average).Average

    switch ($Metric) {
        "R2" {
            $ssTot = ($y | ForEach-Object { ($_ - $mean)*($_ - $mean) } | Measure-Object -Sum).Sum
            $ssRes = 0.0
            for ($i = 0; $i -lt $y.Length; $i++) { $ssRes += ($y[$i] - $preds[$i]) * ($y[$i] - $preds[$i]) }
            if ($ssTot -gt 0) { return 1.0 - $ssRes / $ssTot } else { return 1.0 }
        }
        "RMSE" {
            $mse = 0.0
            for ($i = 0; $i -lt $y.Length; $i++) { $mse += ($y[$i] - $preds[$i]) * ($y[$i] - $preds[$i]) }
            return [Math]::Sqrt($mse / $y.Length)
        }
        "Accuracy" {
            $correct = 0
            for ($i = 0; $i -lt $y.Length; $i++) { if ([int]$preds[$i] -eq [int]$y[$i]) { $correct++ } }
            return $correct / $y.Length
        }
    }
    return 0.0
}

# ============================================================
# TEST
# 1. Run VBAF.LoadAll.ps1
#
# --- Train a model ---
# 2. $data = Get-VBAFDataset -Name "HousePrice"
# $scaler = [StandardScaler]::new()
# $Xs = $scaler.FitTransform($data.X)
# $model = [LinearRegression]::new()
# $model.Fit($Xs, $data.y)
#
# --- Permutation importance ---
# 3. Get-VBAFPermutationImportance -Model $model -ModelType "LinearRegression" `
# -X $Xs -y $data.y -FeatureNames @("size_sqm","bedrooms","age_years")
#
# --- SHAP values ---
# 4. $instance = [double[]]$Xs[0]
# $shap = Get-VBAFSHAPValues -Model $model -Instance $instance -Background $Xs `
# -FeatureNames @("size_sqm","bedrooms","age_years")
# Show-VBAFSHAPExplanation -SHAP $shap -ModelType "LinearRegression"
#
# --- Partial dependence ---
# 5. Get-VBAFPartialDependence -Model $model -X $Xs -FeatureIndex 0 `
# -FeatureName "size_sqm"
#
# --- LIME ---
# 6. Get-VBAFLIMEExplanation -Model $model -Instance $instance -X $Xs `
# -FeatureNames @("size_sqm","bedrooms","age_years")
# ============================================================
Write-Host "📦 VBAF.ML.Explainability.ps1 loaded [v2.1.0 🔍]" -ForegroundColor Green
Write-Host " Functions : Get-VBAFPermutationImportance"      -ForegroundColor Cyan
Write-Host " Get-VBAFSHAPValues"                   -ForegroundColor Cyan
Write-Host " Show-VBAFSHAPExplanation"             -ForegroundColor Cyan
Write-Host " Get-VBAFPartialDependence"            -ForegroundColor Cyan
Write-Host " Get-VBAFLIMEExplanation"              -ForegroundColor Cyan
Write-Host " Get-VBAFScore"                        -ForegroundColor Cyan
Write-Host ""
Write-Host " Quick start:" -ForegroundColor Yellow
Write-Host ' $data = Get-VBAFDataset -Name "HousePrice"'   -ForegroundColor White
Write-Host ' $scaler = [StandardScaler]::new()'              -ForegroundColor White
Write-Host ' $Xs = $scaler.FitTransform($data.X)'        -ForegroundColor White
Write-Host ' $model = [LinearRegression]::new()'            -ForegroundColor White
Write-Host ' $model.Fit($Xs, $data.y)'                       -ForegroundColor White
Write-Host ' Get-VBAFPermutationImportance -Model $model -ModelType "LinearRegression" -X $Xs -y $data.y -FeatureNames @("size_sqm","bedrooms","age_years")' -ForegroundColor White
Write-Host ""