File size: 9,647 Bytes
20cdc88
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
"""Core statistical models and metrics for causal model evaluation (CME)."""

from __future__ import annotations

from dataclasses import dataclass

import numpy as np
import torch
from scipy import stats
from sklearn.gaussian_process import GaussianProcessRegressor
from sklearn.gaussian_process.kernels import ConstantKernel, RBF, WhiteKernel


FORMAT_VERSION = "cme_lagged_parcorr_v1"


def _as_series(data: np.ndarray | torch.Tensor, nodes: int) -> np.ndarray:
    """Normalize [T,N] or [B,T,N] input without joining sample boundaries."""
    array = data.detach().cpu().numpy() if isinstance(data, torch.Tensor) else np.asarray(data)
    if array.ndim == 2:
        array = array[None, ...]
    if array.ndim != 3 or array.shape[-1] != nodes:
        raise ValueError(f"expected [T,{nodes}] or [B,T,{nodes}], got {array.shape}")
    if not np.isfinite(array).all():
        raise ValueError("node time series contains NaN or Inf")
    return array.astype(np.float64, copy=False)


@dataclass
class CausalNetwork:
    """A directed signed lagged network with source-target-lag tensor layout."""

    edges: torch.Tensor
    pvalues: torch.Tensor
    mci: torch.Tensor

    def state_dict(self) -> dict[str, torch.Tensor]:
        return {"edges": self.edges.cpu(), "pvalues": self.pvalues.cpu(), "mci": self.mci.cpu()}

    @classmethod
    def from_state_dict(cls, state: dict[str, torch.Tensor]) -> "CausalNetwork":
        return cls(state["edges"].bool(), state["pvalues"].float(), state["mci"].float())


class LaggedPartialCorrelationCME:
    """Linear conditional-regression approximation to PCMCI's ParCorr MCI step.

    Each target is conditioned on its own history through ``max_lag``. All
    source-lag candidates are then residualized against that common condition
    set and tested with a two-sided t test. This captures directed lagged
    conditional dependence, but does not implement PCMCI's iterative PC stage.
    """

    def __init__(self, nodes: int = 50, max_lag: int = 10, alpha: float = 0.02,
                 ridge: float = 1e-6, exclude_self_links: bool = True):
        self.nodes = int(nodes)
        self.max_lag = int(max_lag)
        self.alpha = float(alpha)
        self.ridge = float(ridge)
        self.exclude_self_links = bool(exclude_self_links)
        if self.nodes <= 1 or self.max_lag < 1 or not 0 < self.alpha < 1:
            raise ValueError("nodes, max_lag, and alpha must define a valid test")

    def fit(self, data: np.ndarray | torch.Tensor) -> CausalNetwork:
        samples = _as_series(data, self.nodes)
        if samples.shape[1] <= self.max_lag + 2:
            raise ValueError("time dimension is too short for requested maximum lag")
        current = np.concatenate([x[self.max_lag:] for x in samples], axis=0)
        lagged = np.concatenate([
            np.stack([x[self.max_lag - lag:-lag] for lag in range(1, self.max_lag + 1)], axis=2)
            for x in samples
        ], axis=0)  # [observations, source, lag]
        candidates = lagged.reshape(len(current), -1)
        candidates -= candidates.mean(axis=0, keepdims=True)
        pvalues = np.ones((self.nodes, self.nodes, self.max_lag), dtype=np.float32)
        mci = np.zeros_like(pvalues)
        for target in range(self.nodes):
            controls = lagged[:, target, :]
            controls = np.column_stack([np.ones(len(controls)), controls])
            gram = controls.T @ controls + self.ridge * np.eye(controls.shape[1])
            projection = np.linalg.solve(gram, controls.T)
            residual_x = candidates - controls @ (projection @ candidates)
            y = current[:, target]
            residual_y = y - controls @ (projection @ y)
            numerator = residual_x.T @ residual_y
            denominator = np.sqrt(np.sum(residual_x ** 2, axis=0) * np.sum(residual_y ** 2))
            correlation = np.divide(numerator, denominator, out=np.zeros_like(numerator), where=denominator > 1e-12)
            correlation = np.clip(correlation, -0.999999, 0.999999)
            dof = max(len(y) - controls.shape[1] - 1, 1)
            statistic = np.abs(correlation) * np.sqrt(dof / np.maximum(1.0 - correlation ** 2, 1e-12))
            probability = 2.0 * stats.t.sf(statistic, dof)
            mci[:, target, :] = correlation.reshape(self.nodes, self.max_lag)
            pvalues[:, target, :] = probability.reshape(self.nodes, self.max_lag)
        if self.exclude_self_links:
            diagonal = np.arange(self.nodes)
            pvalues[diagonal, diagonal, :] = 1.0
            mci[diagonal, diagonal, :] = 0.0
        edges = pvalues < self.alpha
        return CausalNetwork(torch.from_numpy(edges), torch.from_numpy(pvalues), torch.from_numpy(mci))

    def config(self) -> dict:
        return {"nodes": self.nodes, "max_lag": self.max_lag, "alpha": self.alpha,
                "ridge": self.ridge, "exclude_self_links": self.exclude_self_links}


def asymmetric_f1(reference: CausalNetwork, candidate: CausalNetwork, lag_tolerance: int = 2) -> dict[str, float | int]:
    """Compare direction and sign while allowing candidate lag error of +/- tolerance."""
    ref_edges, pred_edges = reference.edges.numpy(), candidate.edges.numpy()
    ref_sign, pred_sign = np.sign(reference.mci.numpy()), np.sign(candidate.mci.numpy())

    def matched(edges_a, sign_a, edges_b, sign_b):
        hits = 0
        for source, target, lag in np.argwhere(edges_a):
            lo, hi = max(0, lag - lag_tolerance), min(edges_b.shape[2], lag + lag_tolerance + 1)
            hits += int(np.any(edges_b[source, target, lo:hi] &
                               (sign_b[source, target, lo:hi] == sign_a[source, target, lag])))
        return hits

    ref_count, pred_count = int(ref_edges.sum()), int(pred_edges.sum())
    recall_hits = matched(ref_edges, ref_sign, pred_edges, pred_sign)
    precision_hits = matched(pred_edges, pred_sign, ref_edges, ref_sign)
    recall = recall_hits / ref_count if ref_count else float(pred_count == 0)
    precision = precision_hits / pred_count if pred_count else float(ref_count == 0)
    f1 = 2 * precision * recall / (precision + recall) if precision + recall else 0.0
    return {"f1": float(f1), "precision": float(precision), "recall": float(recall),
            "reference_edges": ref_count, "candidate_edges": pred_count,
            "precision_matches": precision_hits, "recall_matches": recall_hits,
            "lag_tolerance": int(lag_tolerance)}


def pattern_correlation(reference: np.ndarray, model: np.ndarray, weights: np.ndarray | None = None) -> float:
    reference, model = np.asarray(reference, float).ravel(), np.asarray(model, float).ravel()
    weights = np.ones_like(reference) if weights is None else np.asarray(weights, float).ravel()
    weights = weights / weights.sum()
    ref_centered = reference - np.sum(weights * reference)
    model_centered = model - np.sum(weights * model)
    denominator = np.sqrt(np.sum(weights * ref_centered ** 2) * np.sum(weights * model_centered ** 2))
    return float(np.sum(weights * ref_centered * model_centered) / denominator) if denominator > 0 else 0.0


def taylor_s_score(reference: np.ndarray, model: np.ndarray, weights: np.ndarray | None = None) -> dict[str, float]:
    """Paper Eq. 5: (1+R)^4 / (4*(SDR + 1/SDR)^2)."""
    reference, model = np.asarray(reference, float).ravel(), np.asarray(model, float).ravel()
    weights = np.ones_like(reference) if weights is None else np.asarray(weights, float).ravel()
    weights = weights / weights.sum()
    correlation = pattern_correlation(reference, model, weights)
    ref_std = np.sqrt(np.sum(weights * (reference - np.sum(weights * reference)) ** 2))
    model_std = np.sqrt(np.sum(weights * (model - np.sum(weights * model)) ** 2))
    ratio = model_std / max(ref_std, 1e-12)
    score = (1.0 + correlation) ** 4 / (4.0 * (ratio + 1.0 / max(ratio, 1e-12)) ** 2)
    return {"s_score": float(score), "pattern_correlation": correlation, "standard_deviation_ratio": float(ratio)}


class PrecipitationConstraintGP:
    """RBF plus white-noise GP for F1-to-delta-precipitation constraints."""

    def __init__(self, random_state: int = 42, restarts: int = 2):
        kernel = ConstantKernel(1.0, (1e-3, 1e3)) * RBF(0.15, (1e-2, 10.0)) + WhiteKernel(0.01, (1e-6, 1.0))
        self.model = GaussianProcessRegressor(kernel=kernel, normalize_y=True,
                                              n_restarts_optimizer=int(restarts), random_state=random_state)

    def fit(self, f1_scores: np.ndarray, delta_precipitation: np.ndarray) -> "PrecipitationConstraintGP":
        self.x_train = np.asarray(f1_scores, float).reshape(-1, 1)
        self.y_train = np.asarray(delta_precipitation, float)
        self.model.fit(self.x_train, self.y_train)
        return self

    def predict(self, f1_scores: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
        mean, std = self.model.predict(np.asarray(f1_scores, float).reshape(-1, 1), return_std=True)
        return mean, mean - 1.96 * std, mean + 1.96 * std

    def state_dict(self) -> dict:
        return {"x_train": self.x_train.astype(np.float64), "y_train": self.y_train.astype(np.float64),
                "kernel_theta": self.model.kernel_.theta.astype(np.float64)}

    @classmethod
    def from_state_dict(cls, state: dict, random_state: int = 42) -> "PrecipitationConstraintGP":
        instance = cls(random_state=random_state, restarts=0)
        instance.model.kernel.theta = np.asarray(state["kernel_theta"])
        instance.model.optimizer = None
        instance.model.fit(np.asarray(state["x_train"]), np.asarray(state["y_train"]))
        return instance