antecedent.discovery
Discovery stubs.
1"""Discovery algorithm configuration and helpers.""" 2 3from __future__ import annotations 4 5from dataclasses import dataclass 6from typing import Any, Callable, Literal, Sequence, Union 7 8import numpy as np 9from numpy.typing import NDArray 10 11from ._native import ( 12 DiscoveredLink, 13 GraphEdge, 14 GraphPosterior, 15 PcmciDiscoveryResult, 16 RpcmciDiscoverySummary, 17 discover_ci_screened_posterior as _discover_ci_screened_posterior, 18 discover_dbn_posterior as _discover_dbn_posterior, 19 discover_exact_dag_posterior as _discover_exact_dag_posterior, 20 discover_jpcmci_plus as _discover_jpcmci_plus, 21 discover_lpcmci as _discover_lpcmci, 22 discover_order_mcmc as _discover_order_mcmc, 23 discover_pc as _discover_pc, 24 discover_ges as _discover_ges, 25 discover_lingam as _discover_lingam, 26 discover_notears as _discover_notears, 27 discover_fci as _discover_fci, 28 discover_pcmci as _discover_pcmci, 29 discover_pcmci_plus as _discover_pcmci_plus, 30 discover_rfci as _discover_rfci, 31 discover_rpcmci as _discover_rpcmci, 32 discover_structure_mcmc as _discover_structure_mcmc, 33 two_regime_half_split, 34) 35 36CiSpec = Union[str, Callable[..., Sequence[tuple[float, float]]]] 37 38 39@dataclass(frozen=True) 40class PC: 41 alpha: float = 0.05 42 fdr: bool = True 43 ci: CiSpec = "parcorr" 44 max_cond_size: int = 2 45 kind: Literal["pc"] = "pc" 46 47 48@dataclass(frozen=True) 49class PCMCI: 50 max_lag: int = 1 51 alpha: float = 0.05 52 fdr: bool = True 53 ci: CiSpec = "parcorr" 54 kind: Literal["pcmci"] = "pcmci" 55 56 57@dataclass(frozen=True) 58class PCMCIPlus: 59 max_lag: int = 1 60 alpha: float = 0.05 61 fdr: bool = True 62 ci: CiSpec = "parcorr" 63 kind: Literal["pcmci_plus"] = "pcmci_plus" 64 65 66@dataclass(frozen=True) 67class LPCMCI: 68 max_lag: int = 1 69 alpha: float = 0.05 70 fdr: bool = True 71 ci: CiSpec = "parcorr" 72 kind: Literal["lpcmci"] = "lpcmci" 73 74 75@dataclass(frozen=True) 76class JPCMCIPlus: 77 max_lag: int = 1 78 alpha: float = 0.05 79 fdr: bool = True 80 ci: CiSpec = "parcorr" 81 context_names: tuple[str, ...] = () 82 include_space_dummy: bool = True 83 include_time_dummy: bool = False 84 space_dummy_ci: Literal["scalar", "multivariate"] = "scalar" 85 time_dummy_encoding: Literal["integer", "one_hot"] = "integer" 86 time_dummy_ci: Literal["scalar", "multivariate"] = "scalar" 87 kind: Literal["jpcmci_plus"] = "jpcmci_plus" 88 89 90@dataclass(frozen=True) 91class RPCMCI: 92 """Regime-PCMCI. Pass ``regimes=`` to ``analyze`` / ``discover_rpcmci`` (required). 93 94 Use ``two_regime_half_split(n)`` when a simple half-split label vector is enough. 95 """ 96 97 max_lag: int = 1 98 alpha: float = 0.05 99 fdr: bool = True 100 ci: CiSpec = "parcorr" 101 kind: Literal["rpcmci"] = "rpcmci" 102 103 104@dataclass(frozen=True) 105class GES: 106 alpha: float = 0.05 107 fdr: bool = True 108 ci: CiSpec = "parcorr" 109 max_cond_size: int = 2 110 screen_pc: bool = False 111 max_subset: int | None = None 112 kind: Literal["ges"] = "ges" 113 114 115@dataclass(frozen=True) 116class LiNGAM: 117 prune_threshold: float = 0.05 118 max_cond_size: int = 8 119 kind: Literal["lingam"] = "lingam" 120 121 122@dataclass(frozen=True) 123class NOTEARS: 124 l1: float = 0.1 125 threshold: float = 0.3 126 standardize: bool = True 127 max_cond_size: int = 8 128 kind: Literal["notears"] = "notears" 129 130 131@dataclass(frozen=True) 132class FCI: 133 alpha: float = 0.05 134 fdr: bool = True 135 ci: CiSpec = "parcorr" 136 max_cond_size: int = 2 137 kind: Literal["fci"] = "fci" 138 139 140@dataclass(frozen=True) 141class RFCI: 142 alpha: float = 0.05 143 fdr: bool = True 144 ci: CiSpec = "parcorr" 145 max_cond_size: int = 2 146 kind: Literal["rfci"] = "rfci" 147 148 149@dataclass(frozen=True) 150class ExactDagPosterior: 151 """Exact DAG posterior enumeration (hard limit: n ≤ 6, Gaussian BIC). 152 153 For more variables use ``OrderMcmc``, ``StructureMcmc``, or ``CiScreenedPosterior``. 154 155 Standalone: ``discover_exact_dag_posterior(...)`` returns a ``GraphPosterior``. 156 Composed: pass ``discovery=ExactDagPosterior()`` with ``inference=Bayesian(...)`` 157 to ``analyze`` to mix effect draws over the graph posterior (P1-D). 158 """ 159 160 kind: Literal["exact_dag_posterior"] = "exact_dag_posterior" 161 162 163@dataclass(frozen=True) 164class OrderMcmc: 165 n_chains: int = 4 166 n_warmup: int = 500 167 n_draws: int = 1000 168 thin: int = 1 169 require_diagnostics_gate: bool = True 170 kind: Literal["order_mcmc"] = "order_mcmc" 171 172 173@dataclass(frozen=True) 174class StructureMcmc: 175 n_chains: int = 4 176 n_warmup: int = 500 177 n_draws: int = 1000 178 thin: int = 1 179 kind: Literal["structure_mcmc"] = "structure_mcmc" 180 181 182@dataclass(frozen=True) 183class CiScreenedPosterior: 184 alpha: float = 0.05 185 fdr: bool = True 186 ci: str = "parcorr" 187 max_cond_size: int = 2 188 soft_weight: Literal["none", "bayes_factor", "posterior_dependence"] = "none" 189 n_chains: int = 2 190 n_warmup: int = 300 191 n_draws: int = 600 192 thin: int = 1 193 kind: Literal["ci_screened_posterior"] = "ci_screened_posterior" 194 195 196@dataclass(frozen=True) 197class DbnPosterior: 198 """Bounded-lag DBN posterior (Gaussian BIC). 199 200 Exact enumeration only when ``p ≤ 4`` and ``max_lag ≤ 2``; larger templates 201 automatically use MCMC (or set ``force_mcmc=True``). 202 203 Standalone: ``discover_dbn_posterior(...)`` returns a ``GraphPosterior``. 204 Composed: pass ``discovery=DbnPosterior(...)`` with ``inference=Bayesian(...)`` 205 and ``PulseEffect``/``SustainedEffect`` to mix temporal effect draws (P1-D). 206 """ 207 208 max_lag: int = 1 209 force_mcmc: bool = False 210 n_chains: int = 2 211 n_warmup: int = 200 212 n_draws: int = 400 213 kind: Literal["dbn_posterior"] = "dbn_posterior" 214 215 216# Alias: DiscoveryResult is the preferred name; PcmciDiscoveryResult kept for compat. 217DiscoveryResult = PcmciDiscoveryResult 218 219 220def discovery_to_dag(result: DiscoveryResult) -> "Dag": 221 """Build a ``Dag`` from a discovery result's directed ``graph_edges``. 222 223 Raises ``ValueError`` if any undirected/circle marks remain. 224 """ 225 from .graph import Dag 226 227 names: list[str] = [] 228 seen: set[str] = set() 229 directed: list[tuple[str, str]] = [] 230 for e in result.graph_edges: 231 for n in (e.source, e.target): 232 if n not in seen: 233 seen.add(n) 234 names.append(n) 235 if e.at_source == "tail" and e.at_target == "arrow": 236 directed.append((e.source, e.target)) 237 elif e.at_source == "arrow" and e.at_target == "tail": 238 directed.append((e.target, e.source)) 239 else: 240 raise ValueError( 241 f"cannot coerce edge {e.source}->{e.target} " 242 f"({e.at_source}/{e.at_target}) into a DAG; " 243 "use graph_edges or a CPDAG/PAG constructor" 244 ) 245 return Dag.from_edges(names, directed) 246 247 248def _coerce_tabular( 249 names_or_data: Any | None = None, 250 columns: Sequence[NDArray[np.float64]] | None = None, 251 *, 252 data: Any | None = None, 253 names: list[str] | None = None, 254) -> tuple[list[str], list[NDArray[np.float64]]]: 255 """Accept ``discover_*(data)``, ``discover_*(names, columns)``, or kwargs.""" 256 from ._data import as_columns, coerce_data_args, to_f64 257 258 if data is not None: 259 return as_columns(data) 260 if columns is not None: 261 if names_or_data is None: 262 raise TypeError("columns= requires names as the first argument") 263 return [str(n) for n in names_or_data], [to_f64(c) for c in columns] 264 if names is not None: 265 # names= kw-only without columns — need columns via coerce 266 return coerce_data_args(None, names=names, columns=None) 267 if names_or_data is not None: 268 return as_columns(names_or_data) 269 raise TypeError("provide data=… or names + columns") 270 271 272def discover_pc( 273 names: Any | None = None, 274 columns: Sequence[NDArray[np.float64]] | None = None, 275 *, 276 data: Any | None = None, 277 alpha: float = 0.05, 278 fdr: bool = True, 279 seed: int = 1, 280 ci: str = "parcorr", 281 max_cond_size: int = 2, 282 threads: int = 1, 283) -> DiscoveryResult: 284 n, cols = _coerce_tabular(names, columns, data=data) 285 return _discover_pc( 286 n, 287 cols, 288 alpha=alpha, 289 fdr=fdr, 290 seed=seed, 291 ci=ci, 292 max_cond_size=max_cond_size, 293 threads=threads, 294 ) 295 296 297def discover_ges( 298 names: Any | None = None, 299 columns: Sequence[NDArray[np.float64]] | None = None, 300 *, 301 data: Any | None = None, 302 alpha: float = 0.05, 303 fdr: bool = True, 304 seed: int = 1, 305 ci: str = "parcorr", 306 max_cond_size: int = 2, 307 threads: int = 1, 308 screen_pc: bool = False, 309 max_subset: int | None = None, 310) -> DiscoveryResult: 311 n, cols = _coerce_tabular(names, columns, data=data) 312 return _discover_ges( 313 n, 314 cols, 315 alpha=alpha, 316 fdr=fdr, 317 seed=seed, 318 ci=ci, 319 max_cond_size=max_cond_size, 320 threads=threads, 321 screen_pc=screen_pc, 322 max_subset=max_subset, 323 ) 324 325 326def discover_lingam( 327 names: Any | None = None, 328 columns: Sequence[NDArray[np.float64]] | None = None, 329 *, 330 data: Any | None = None, 331 prune_threshold: float = 0.05, 332 seed: int = 1, 333 max_cond_size: int = 8, 334 threads: int = 1, 335) -> DiscoveryResult: 336 n, cols = _coerce_tabular(names, columns, data=data) 337 return _discover_lingam( 338 n, 339 cols, 340 prune_threshold=prune_threshold, 341 seed=seed, 342 max_cond_size=max_cond_size, 343 threads=threads, 344 ) 345 346 347def discover_notears( 348 names: Any | None = None, 349 columns: Sequence[NDArray[np.float64]] | None = None, 350 *, 351 data: Any | None = None, 352 l1: float = 0.1, 353 threshold: float = 0.3, 354 standardize: bool = True, 355 seed: int = 1, 356 max_cond_size: int = 8, 357 threads: int = 1, 358) -> DiscoveryResult: 359 n, cols = _coerce_tabular(names, columns, data=data) 360 return _discover_notears( 361 n, 362 cols, 363 l1=l1, 364 threshold=threshold, 365 standardize=standardize, 366 seed=seed, 367 max_cond_size=max_cond_size, 368 threads=threads, 369 ) 370 371 372def discover_fci( 373 names: Any | None = None, 374 columns: Sequence[NDArray[np.float64]] | None = None, 375 *, 376 data: Any | None = None, 377 alpha: float = 0.05, 378 fdr: bool = True, 379 seed: int = 1, 380 ci: str = "parcorr", 381 max_cond_size: int = 2, 382 threads: int = 1, 383) -> DiscoveryResult: 384 n, cols = _coerce_tabular(names, columns, data=data) 385 return _discover_fci( 386 n, 387 cols, 388 alpha=alpha, 389 fdr=fdr, 390 seed=seed, 391 ci=ci, 392 max_cond_size=max_cond_size, 393 threads=threads, 394 ) 395 396 397def discover_rfci( 398 names: Any | None = None, 399 columns: Sequence[NDArray[np.float64]] | None = None, 400 *, 401 data: Any | None = None, 402 alpha: float = 0.05, 403 fdr: bool = True, 404 seed: int = 1, 405 ci: str = "parcorr", 406 max_cond_size: int = 2, 407 threads: int = 1, 408) -> DiscoveryResult: 409 n, cols = _coerce_tabular(names, columns, data=data) 410 return _discover_rfci( 411 n, 412 cols, 413 alpha=alpha, 414 fdr=fdr, 415 seed=seed, 416 ci=ci, 417 max_cond_size=max_cond_size, 418 threads=threads, 419 ) 420 421 422def discover_pcmci( 423 names: Any | None = None, 424 columns: Sequence[NDArray[np.float64]] | None = None, 425 *, 426 data: Any | None = None, 427 max_lag: int = 1, 428 alpha: float = 0.05, 429 fdr: bool = True, 430 seed: int = 1, 431 ci: str = "parcorr", 432 weights: list[float] | None = None, 433 threads: int = 1, 434) -> DiscoveryResult: 435 n, cols = _coerce_tabular(names, columns, data=data) 436 return _discover_pcmci( 437 n, 438 cols, 439 max_lag=max_lag, 440 alpha=alpha, 441 fdr=fdr, 442 seed=seed, 443 ci=ci, 444 weights=weights, 445 threads=threads, 446 ) 447 448 449def discover_pcmci_plus( 450 names: Any | None = None, 451 columns: Sequence[NDArray[np.float64]] | None = None, 452 *, 453 data: Any | None = None, 454 max_lag: int = 1, 455 alpha: float = 0.05, 456 fdr: bool = True, 457 seed: int = 1, 458 ci: str = "parcorr", 459 weights: list[float] | None = None, 460 threads: int = 1, 461) -> DiscoveryResult: 462 n, cols = _coerce_tabular(names, columns, data=data) 463 return _discover_pcmci_plus( 464 n, 465 cols, 466 max_lag=max_lag, 467 alpha=alpha, 468 fdr=fdr, 469 seed=seed, 470 ci=ci, 471 weights=weights, 472 threads=threads, 473 ) 474 475 476def discover_lpcmci( 477 names: Any | None = None, 478 columns: Sequence[NDArray[np.float64]] | None = None, 479 *, 480 data: Any | None = None, 481 max_lag: int = 1, 482 alpha: float = 0.05, 483 fdr: bool = True, 484 seed: int = 1, 485 ci: str = "parcorr", 486 weights: list[float] | None = None, 487 threads: int = 1, 488) -> DiscoveryResult: 489 n, cols = _coerce_tabular(names, columns, data=data) 490 return _discover_lpcmci( 491 n, 492 cols, 493 max_lag=max_lag, 494 alpha=alpha, 495 fdr=fdr, 496 seed=seed, 497 ci=ci, 498 weights=weights, 499 threads=threads, 500 ) 501 502 503def discover_jpcmci_plus( 504 names: list[str], 505 env_columns: Sequence[Sequence[NDArray[np.float64]]], 506 *, 507 max_lag: int = 1, 508 alpha: float = 0.05, 509 fdr: bool = True, 510 seed: int = 1, 511 ci: str = "parcorr", 512 weights: list[float] | None = None, 513 threads: int = 1, 514 context_names: Sequence[str] | None = None, 515 include_space_dummy: bool = True, 516 include_time_dummy: bool = False, 517 space_dummy_ci: str = "scalar", 518 time_dummy_encoding: str = "integer", 519 time_dummy_ci: str = "scalar", 520) -> PcmciDiscoveryResult: 521 return _discover_jpcmci_plus( 522 names, 523 [list(cols) for cols in env_columns], 524 max_lag=max_lag, 525 alpha=alpha, 526 fdr=fdr, 527 seed=seed, 528 ci=ci, 529 weights=weights, 530 threads=threads, 531 context_names=list(context_names) if context_names is not None else None, 532 include_space_dummy=include_space_dummy, 533 include_time_dummy=include_time_dummy, 534 space_dummy_ci=space_dummy_ci, 535 time_dummy_encoding=time_dummy_encoding, 536 time_dummy_ci=time_dummy_ci, 537 ) 538 539 540def discover_rpcmci( 541 names: Any | None = None, 542 columns: Sequence[NDArray[np.float64]] | None = None, 543 *, 544 data: Any | None = None, 545 regimes: Sequence[int], 546 max_lag: int = 1, 547 alpha: float = 0.05, 548 fdr: bool = True, 549 seed: int = 1, 550 ci: str = "parcorr", 551 weights: list[float] | None = None, 552 threads: int = 1, 553) -> RpcmciDiscoverySummary: 554 """Run RPCMCI. ``regimes`` is required (length = series length); no silent half-split. 555 556 Call ``two_regime_half_split(len(series))`` for an explicit two-regime mid-point split. 557 """ 558 n, cols = _coerce_tabular(names, columns, data=data) 559 return _discover_rpcmci( 560 n, 561 cols, 562 regimes=list(regimes), 563 max_lag=max_lag, 564 alpha=alpha, 565 fdr=fdr, 566 seed=seed, 567 ci=ci, 568 weights=weights, 569 threads=threads, 570 ) 571 572 573def discover_exact_dag_posterior( 574 names: Any | None = None, 575 columns: Sequence[NDArray[np.float64]] | None = None, 576 *, 577 data: Any | None = None, 578 seed: int = 1, 579 threads: int = 1, 580) -> GraphPosterior: 581 """Exact DAG posterior (hard limit n ≤ 6). Prefer MCMC helpers for larger graphs.""" 582 n, cols = _coerce_tabular(names, columns, data=data) 583 return _discover_exact_dag_posterior(n, cols, seed=seed, threads=threads) 584 585 586def discover_order_mcmc( 587 names: Any | None = None, 588 columns: Sequence[NDArray[np.float64]] | None = None, 589 *, 590 data: Any | None = None, 591 n_chains: int = 4, 592 n_warmup: int = 500, 593 n_draws: int = 1000, 594 thin: int = 1, 595 require_diagnostics_gate: bool = True, 596 seed: int = 1, 597 threads: int = 1, 598) -> GraphPosterior: 599 n, cols = _coerce_tabular(names, columns, data=data) 600 return _discover_order_mcmc( 601 n, 602 cols, 603 n_chains=n_chains, 604 n_warmup=n_warmup, 605 n_draws=n_draws, 606 thin=thin, 607 require_diagnostics_gate=require_diagnostics_gate, 608 seed=seed, 609 threads=threads, 610 ) 611 612 613def discover_structure_mcmc( 614 names: Any | None = None, 615 columns: Sequence[NDArray[np.float64]] | None = None, 616 *, 617 data: Any | None = None, 618 n_chains: int = 4, 619 n_warmup: int = 500, 620 n_draws: int = 1000, 621 thin: int = 1, 622 seed: int = 1, 623 threads: int = 1, 624) -> GraphPosterior: 625 n, cols = _coerce_tabular(names, columns, data=data) 626 return _discover_structure_mcmc( 627 n, 628 cols, 629 n_chains=n_chains, 630 n_warmup=n_warmup, 631 n_draws=n_draws, 632 thin=thin, 633 seed=seed, 634 threads=threads, 635 ) 636 637 638def discover_ci_screened_posterior( 639 names: Any | None = None, 640 columns: Sequence[NDArray[np.float64]] | None = None, 641 *, 642 data: Any | None = None, 643 alpha: float = 0.05, 644 fdr: bool = True, 645 seed: int = 1, 646 ci: str = "parcorr", 647 max_cond_size: int = 2, 648 soft_weight: str = "none", 649 n_chains: int = 2, 650 n_warmup: int = 300, 651 n_draws: int = 600, 652 thin: int = 1, 653 threads: int = 1, 654) -> GraphPosterior: 655 n, cols = _coerce_tabular(names, columns, data=data) 656 return _discover_ci_screened_posterior( 657 n, 658 cols, 659 alpha=alpha, 660 fdr=fdr, 661 ci=ci, 662 max_cond_size=max_cond_size, 663 soft_weight=soft_weight, 664 n_chains=n_chains, 665 n_warmup=n_warmup, 666 n_draws=n_draws, 667 thin=thin, 668 seed=seed, 669 threads=threads, 670 ) 671 672 673def discover_dbn_posterior( 674 names: Any | None = None, 675 columns: Sequence[NDArray[np.float64]] | None = None, 676 *, 677 data: Any | None = None, 678 max_lag: int = 1, 679 force_mcmc: bool = False, 680 n_chains: int = 2, 681 n_warmup: int = 200, 682 n_draws: int = 400, 683 seed: int = 1, 684 threads: int = 1, 685) -> GraphPosterior: 686 """DBN template posterior; exact only for p ≤ 4 and max_lag ≤ 2, else MCMC.""" 687 n, cols = _coerce_tabular(names, columns, data=data) 688 return _discover_dbn_posterior( 689 n, 690 cols, 691 max_lag=max_lag, 692 force_mcmc=force_mcmc, 693 n_chains=n_chains, 694 n_warmup=n_warmup, 695 n_draws=n_draws, 696 seed=seed, 697 threads=threads, 698 ) 699 700 701 702def graph_posterior_map_edges(post: "GraphPosterior") -> list[tuple[str, str]]: 703 """Oriented edges from the maximum-weight adjacency mask in a graph posterior.""" 704 import numpy as np 705 706 if post.n_graphs < 1 or not post.weights: 707 raise ValueError("GraphPosterior has no graphs") 708 i = int(np.argmax(np.asarray(post.weights, dtype=np.float64))) 709 mask = int(post.adjacency[i]) 710 n = int(post.n_vars) 711 names = list(post.names) 712 if len(names) < n: 713 names = [f"x{j}" for j in range(n)] 714 edges: list[tuple[str, str]] = [] 715 for fr in range(n): 716 for to in range(n): 717 if fr == to: 718 continue 719 # edge_bit(n, from, to) = from*(n-1) + (to if to < from else to-1) 720 bit = fr * (n - 1) + (to if to < fr else to - 1) 721 if (mask >> bit) & 1: 722 edges.append((names[fr], names[to])) 723 return edges 724 725 726def graph_posterior_map_dag(post: "GraphPosterior") -> "Dag": 727 """MAP DAG from a graph posterior (maximum-weight atom).""" 728 from .graph import Dag 729 730 edges = graph_posterior_map_edges(post) 731 names = list(post.names) if post.names else sorted({a for e in edges for a in e}) 732 return Dag.from_edges(names, edges) 733 734 735def cpdag_oriented_edges(cpdag: "Cpdag", *, require_oriented: bool = True) -> list[tuple[str, str]]: 736 """Return directed edges from a CPDAG; error if undirected remain when required.""" 737 from .graph import Cpdag 738 739 if not isinstance(cpdag, Cpdag): 740 raise TypeError(f"expected Cpdag, got {type(cpdag)!r}") 741 directed: list[tuple[str, str]] = [] 742 undirected = 0 743 for src, tgt, kind in cpdag.edges(): 744 if kind == "directed": 745 directed.append((src, tgt)) 746 elif kind == "undirected": 747 undirected += 1 748 else: 749 undirected += 1 750 if undirected and require_oriented: 751 raise ValueError( 752 f"CPDAG has {undirected} undirected/ambiguous edge(s); orient before " 753 "PathSpecific/Interventional queries (require_oriented=True)" 754 ) 755 if require_oriented: 756 try: 757 dag = cpdag.try_into_dag() 758 except Exception as exc: # noqa: BLE001 759 raise ValueError( 760 "CPDAG is not fully oriented; cannot coerce to DAG for path/distribution queries" 761 ) from exc 762 return list(dag.edges()) 763 return directed 764 765 766__all__ = [ 767 "CiScreenedPosterior", 768 "DbnPosterior", 769 "DiscoveredLink", 770 "DiscoveryResult", 771 "ExactDagPosterior", 772 "FCI", 773 "GES", 774 "GraphEdge", 775 "GraphPosterior", 776 "JPCMCIPlus", 777 "LPCMCI", 778 "LiNGAM", 779 "NOTEARS", 780 "OrderMcmc", 781 "PC", 782 "PCMCI", 783 "PCMCIPlus", 784 "PcmciDiscoveryResult", 785 "RFCI", 786 "RPCMCI", 787 "RpcmciDiscoverySummary", 788 "StructureMcmc", 789 "discover_ci_screened_posterior", 790 "discover_dbn_posterior", 791 "discover_exact_dag_posterior", 792 "discover_jpcmci_plus", 793 "discover_lpcmci", 794 "discover_order_mcmc", 795 "discover_pc", 796 "discover_ges", 797 "discover_lingam", 798 "discover_notears", 799 "discover_fci", 800 "discover_rfci", 801 "discover_pcmci", 802 "discover_pcmci_plus", 803 "discover_rpcmci", 804 "discover_structure_mcmc", 805 "cpdag_oriented_edges", 806 "discovery_to_dag", 807 "graph_posterior_map_dag", 808 "graph_posterior_map_edges", 809 "two_regime_half_split", 810]
183@dataclass(frozen=True) 184class CiScreenedPosterior: 185 alpha: float = 0.05 186 fdr: bool = True 187 ci: str = "parcorr" 188 max_cond_size: int = 2 189 soft_weight: Literal["none", "bayes_factor", "posterior_dependence"] = "none" 190 n_chains: int = 2 191 n_warmup: int = 300 192 n_draws: int = 600 193 thin: int = 1 194 kind: Literal["ci_screened_posterior"] = "ci_screened_posterior"
197@dataclass(frozen=True) 198class DbnPosterior: 199 """Bounded-lag DBN posterior (Gaussian BIC). 200 201 Exact enumeration only when ``p ≤ 4`` and ``max_lag ≤ 2``; larger templates 202 automatically use MCMC (or set ``force_mcmc=True``). 203 204 Standalone: ``discover_dbn_posterior(...)`` returns a ``GraphPosterior``. 205 Composed: pass ``discovery=DbnPosterior(...)`` with ``inference=Bayesian(...)`` 206 and ``PulseEffect``/``SustainedEffect`` to mix temporal effect draws (P1-D). 207 """ 208 209 max_lag: int = 1 210 force_mcmc: bool = False 211 n_chains: int = 2 212 n_warmup: int = 200 213 n_draws: int = 400 214 kind: Literal["dbn_posterior"] = "dbn_posterior"
Bounded-lag DBN posterior (Gaussian BIC).
Exact enumeration only when p ≤ 4 and max_lag ≤ 2; larger templates
automatically use MCMC (or set force_mcmc=True).
Standalone: discover_dbn_posterior(...) returns a GraphPosterior.
Composed: pass discovery=DbnPosterior(...) with inference=Bayesian(...)
and PulseEffect/SustainedEffect to mix temporal effect draws (P1-D).
One discovered lagged link for Python.
150@dataclass(frozen=True) 151class ExactDagPosterior: 152 """Exact DAG posterior enumeration (hard limit: n ≤ 6, Gaussian BIC). 153 154 For more variables use ``OrderMcmc``, ``StructureMcmc``, or ``CiScreenedPosterior``. 155 156 Standalone: ``discover_exact_dag_posterior(...)`` returns a ``GraphPosterior``. 157 Composed: pass ``discovery=ExactDagPosterior()`` with ``inference=Bayesian(...)`` 158 to ``analyze`` to mix effect draws over the graph posterior (P1-D). 159 """ 160 161 kind: Literal["exact_dag_posterior"] = "exact_dag_posterior"
Exact DAG posterior enumeration (hard limit: n ≤ 6, Gaussian BIC).
For more variables use OrderMcmc, StructureMcmc, or CiScreenedPosterior.
Standalone: discover_exact_dag_posterior(...) returns a GraphPosterior.
Composed: pass discovery=ExactDagPosterior() with inference=Bayesian(...)
to analyze to mix effect draws over the graph posterior (P1-D).
132@dataclass(frozen=True) 133class FCI: 134 alpha: float = 0.05 135 fdr: bool = True 136 ci: CiSpec = "parcorr" 137 max_cond_size: int = 2 138 kind: Literal["fci"] = "fci"
105@dataclass(frozen=True) 106class GES: 107 alpha: float = 0.05 108 fdr: bool = True 109 ci: CiSpec = "parcorr" 110 max_cond_size: int = 2 111 screen_pc: bool = False 112 max_subset: int | None = None 113 kind: Literal["ges"] = "ges"
One marked edge from an oriented temporal CPDAG/PAG.
Columnar graph posterior returned by Bayesian discovery engines.
76@dataclass(frozen=True) 77class JPCMCIPlus: 78 max_lag: int = 1 79 alpha: float = 0.05 80 fdr: bool = True 81 ci: CiSpec = "parcorr" 82 context_names: tuple[str, ...] = () 83 include_space_dummy: bool = True 84 include_time_dummy: bool = False 85 space_dummy_ci: Literal["scalar", "multivariate"] = "scalar" 86 time_dummy_encoding: Literal["integer", "one_hot"] = "integer" 87 time_dummy_ci: Literal["scalar", "multivariate"] = "scalar" 88 kind: Literal["jpcmci_plus"] = "jpcmci_plus"
67@dataclass(frozen=True) 68class LPCMCI: 69 max_lag: int = 1 70 alpha: float = 0.05 71 fdr: bool = True 72 ci: CiSpec = "parcorr" 73 kind: Literal["lpcmci"] = "lpcmci"
116@dataclass(frozen=True) 117class LiNGAM: 118 prune_threshold: float = 0.05 119 max_cond_size: int = 8 120 kind: Literal["lingam"] = "lingam"
123@dataclass(frozen=True) 124class NOTEARS: 125 l1: float = 0.1 126 threshold: float = 0.3 127 standardize: bool = True 128 max_cond_size: int = 8 129 kind: Literal["notears"] = "notears"
164@dataclass(frozen=True) 165class OrderMcmc: 166 n_chains: int = 4 167 n_warmup: int = 500 168 n_draws: int = 1000 169 thin: int = 1 170 require_diagnostics_gate: bool = True 171 kind: Literal["order_mcmc"] = "order_mcmc"
40@dataclass(frozen=True) 41class PC: 42 alpha: float = 0.05 43 fdr: bool = True 44 ci: CiSpec = "parcorr" 45 max_cond_size: int = 2 46 kind: Literal["pc"] = "pc"
49@dataclass(frozen=True) 50class PCMCI: 51 max_lag: int = 1 52 alpha: float = 0.05 53 fdr: bool = True 54 ci: CiSpec = "parcorr" 55 kind: Literal["pcmci"] = "pcmci"
58@dataclass(frozen=True) 59class PCMCIPlus: 60 max_lag: int = 1 61 alpha: float = 0.05 62 fdr: bool = True 63 ci: CiSpec = "parcorr" 64 kind: Literal["pcmci_plus"] = "pcmci_plus"
Coarse-grained PCMCI discovery result (single boundary crossing).
Field set is the stable Rust↔Python temporal discovery schema for .
141@dataclass(frozen=True) 142class RFCI: 143 alpha: float = 0.05 144 fdr: bool = True 145 ci: CiSpec = "parcorr" 146 max_cond_size: int = 2 147 kind: Literal["rfci"] = "rfci"
91@dataclass(frozen=True) 92class RPCMCI: 93 """Regime-PCMCI. Pass ``regimes=`` to ``analyze`` / ``discover_rpcmci`` (required). 94 95 Use ``two_regime_half_split(n)`` when a simple half-split label vector is enough. 96 """ 97 98 max_lag: int = 1 99 alpha: float = 0.05 100 fdr: bool = True 101 ci: CiSpec = "parcorr" 102 kind: Literal["rpcmci"] = "rpcmci"
Regime-PCMCI. Pass regimes= to analyze / discover_rpcmci (required).
Use two_regime_half_split(n) when a simple half-split label vector is enough.
RPCMCI summary (typed regimes, no single-graph collapse).
174@dataclass(frozen=True) 175class StructureMcmc: 176 n_chains: int = 4 177 n_warmup: int = 500 178 n_draws: int = 1000 179 thin: int = 1 180 kind: Literal["structure_mcmc"] = "structure_mcmc"
639def discover_ci_screened_posterior( 640 names: Any | None = None, 641 columns: Sequence[NDArray[np.float64]] | None = None, 642 *, 643 data: Any | None = None, 644 alpha: float = 0.05, 645 fdr: bool = True, 646 seed: int = 1, 647 ci: str = "parcorr", 648 max_cond_size: int = 2, 649 soft_weight: str = "none", 650 n_chains: int = 2, 651 n_warmup: int = 300, 652 n_draws: int = 600, 653 thin: int = 1, 654 threads: int = 1, 655) -> GraphPosterior: 656 n, cols = _coerce_tabular(names, columns, data=data) 657 return _discover_ci_screened_posterior( 658 n, 659 cols, 660 alpha=alpha, 661 fdr=fdr, 662 ci=ci, 663 max_cond_size=max_cond_size, 664 soft_weight=soft_weight, 665 n_chains=n_chains, 666 n_warmup=n_warmup, 667 n_draws=n_draws, 668 thin=thin, 669 seed=seed, 670 threads=threads, 671 )
674def discover_dbn_posterior( 675 names: Any | None = None, 676 columns: Sequence[NDArray[np.float64]] | None = None, 677 *, 678 data: Any | None = None, 679 max_lag: int = 1, 680 force_mcmc: bool = False, 681 n_chains: int = 2, 682 n_warmup: int = 200, 683 n_draws: int = 400, 684 seed: int = 1, 685 threads: int = 1, 686) -> GraphPosterior: 687 """DBN template posterior; exact only for p ≤ 4 and max_lag ≤ 2, else MCMC.""" 688 n, cols = _coerce_tabular(names, columns, data=data) 689 return _discover_dbn_posterior( 690 n, 691 cols, 692 max_lag=max_lag, 693 force_mcmc=force_mcmc, 694 n_chains=n_chains, 695 n_warmup=n_warmup, 696 n_draws=n_draws, 697 seed=seed, 698 threads=threads, 699 )
DBN template posterior; exact only for p ≤ 4 and max_lag ≤ 2, else MCMC.
574def discover_exact_dag_posterior( 575 names: Any | None = None, 576 columns: Sequence[NDArray[np.float64]] | None = None, 577 *, 578 data: Any | None = None, 579 seed: int = 1, 580 threads: int = 1, 581) -> GraphPosterior: 582 """Exact DAG posterior (hard limit n ≤ 6). Prefer MCMC helpers for larger graphs.""" 583 n, cols = _coerce_tabular(names, columns, data=data) 584 return _discover_exact_dag_posterior(n, cols, seed=seed, threads=threads)
Exact DAG posterior (hard limit n ≤ 6). Prefer MCMC helpers for larger graphs.
504def discover_jpcmci_plus( 505 names: list[str], 506 env_columns: Sequence[Sequence[NDArray[np.float64]]], 507 *, 508 max_lag: int = 1, 509 alpha: float = 0.05, 510 fdr: bool = True, 511 seed: int = 1, 512 ci: str = "parcorr", 513 weights: list[float] | None = None, 514 threads: int = 1, 515 context_names: Sequence[str] | None = None, 516 include_space_dummy: bool = True, 517 include_time_dummy: bool = False, 518 space_dummy_ci: str = "scalar", 519 time_dummy_encoding: str = "integer", 520 time_dummy_ci: str = "scalar", 521) -> PcmciDiscoveryResult: 522 return _discover_jpcmci_plus( 523 names, 524 [list(cols) for cols in env_columns], 525 max_lag=max_lag, 526 alpha=alpha, 527 fdr=fdr, 528 seed=seed, 529 ci=ci, 530 weights=weights, 531 threads=threads, 532 context_names=list(context_names) if context_names is not None else None, 533 include_space_dummy=include_space_dummy, 534 include_time_dummy=include_time_dummy, 535 space_dummy_ci=space_dummy_ci, 536 time_dummy_encoding=time_dummy_encoding, 537 time_dummy_ci=time_dummy_ci, 538 )
477def discover_lpcmci( 478 names: Any | None = None, 479 columns: Sequence[NDArray[np.float64]] | None = None, 480 *, 481 data: Any | None = None, 482 max_lag: int = 1, 483 alpha: float = 0.05, 484 fdr: bool = True, 485 seed: int = 1, 486 ci: str = "parcorr", 487 weights: list[float] | None = None, 488 threads: int = 1, 489) -> DiscoveryResult: 490 n, cols = _coerce_tabular(names, columns, data=data) 491 return _discover_lpcmci( 492 n, 493 cols, 494 max_lag=max_lag, 495 alpha=alpha, 496 fdr=fdr, 497 seed=seed, 498 ci=ci, 499 weights=weights, 500 threads=threads, 501 )
587def discover_order_mcmc( 588 names: Any | None = None, 589 columns: Sequence[NDArray[np.float64]] | None = None, 590 *, 591 data: Any | None = None, 592 n_chains: int = 4, 593 n_warmup: int = 500, 594 n_draws: int = 1000, 595 thin: int = 1, 596 require_diagnostics_gate: bool = True, 597 seed: int = 1, 598 threads: int = 1, 599) -> GraphPosterior: 600 n, cols = _coerce_tabular(names, columns, data=data) 601 return _discover_order_mcmc( 602 n, 603 cols, 604 n_chains=n_chains, 605 n_warmup=n_warmup, 606 n_draws=n_draws, 607 thin=thin, 608 require_diagnostics_gate=require_diagnostics_gate, 609 seed=seed, 610 threads=threads, 611 )
273def discover_pc( 274 names: Any | None = None, 275 columns: Sequence[NDArray[np.float64]] | None = None, 276 *, 277 data: Any | None = None, 278 alpha: float = 0.05, 279 fdr: bool = True, 280 seed: int = 1, 281 ci: str = "parcorr", 282 max_cond_size: int = 2, 283 threads: int = 1, 284) -> DiscoveryResult: 285 n, cols = _coerce_tabular(names, columns, data=data) 286 return _discover_pc( 287 n, 288 cols, 289 alpha=alpha, 290 fdr=fdr, 291 seed=seed, 292 ci=ci, 293 max_cond_size=max_cond_size, 294 threads=threads, 295 )
298def discover_ges( 299 names: Any | None = None, 300 columns: Sequence[NDArray[np.float64]] | None = None, 301 *, 302 data: Any | None = None, 303 alpha: float = 0.05, 304 fdr: bool = True, 305 seed: int = 1, 306 ci: str = "parcorr", 307 max_cond_size: int = 2, 308 threads: int = 1, 309 screen_pc: bool = False, 310 max_subset: int | None = None, 311) -> DiscoveryResult: 312 n, cols = _coerce_tabular(names, columns, data=data) 313 return _discover_ges( 314 n, 315 cols, 316 alpha=alpha, 317 fdr=fdr, 318 seed=seed, 319 ci=ci, 320 max_cond_size=max_cond_size, 321 threads=threads, 322 screen_pc=screen_pc, 323 max_subset=max_subset, 324 )
327def discover_lingam( 328 names: Any | None = None, 329 columns: Sequence[NDArray[np.float64]] | None = None, 330 *, 331 data: Any | None = None, 332 prune_threshold: float = 0.05, 333 seed: int = 1, 334 max_cond_size: int = 8, 335 threads: int = 1, 336) -> DiscoveryResult: 337 n, cols = _coerce_tabular(names, columns, data=data) 338 return _discover_lingam( 339 n, 340 cols, 341 prune_threshold=prune_threshold, 342 seed=seed, 343 max_cond_size=max_cond_size, 344 threads=threads, 345 )
348def discover_notears( 349 names: Any | None = None, 350 columns: Sequence[NDArray[np.float64]] | None = None, 351 *, 352 data: Any | None = None, 353 l1: float = 0.1, 354 threshold: float = 0.3, 355 standardize: bool = True, 356 seed: int = 1, 357 max_cond_size: int = 8, 358 threads: int = 1, 359) -> DiscoveryResult: 360 n, cols = _coerce_tabular(names, columns, data=data) 361 return _discover_notears( 362 n, 363 cols, 364 l1=l1, 365 threshold=threshold, 366 standardize=standardize, 367 seed=seed, 368 max_cond_size=max_cond_size, 369 threads=threads, 370 )
373def discover_fci( 374 names: Any | None = None, 375 columns: Sequence[NDArray[np.float64]] | None = None, 376 *, 377 data: Any | None = None, 378 alpha: float = 0.05, 379 fdr: bool = True, 380 seed: int = 1, 381 ci: str = "parcorr", 382 max_cond_size: int = 2, 383 threads: int = 1, 384) -> DiscoveryResult: 385 n, cols = _coerce_tabular(names, columns, data=data) 386 return _discover_fci( 387 n, 388 cols, 389 alpha=alpha, 390 fdr=fdr, 391 seed=seed, 392 ci=ci, 393 max_cond_size=max_cond_size, 394 threads=threads, 395 )
398def discover_rfci( 399 names: Any | None = None, 400 columns: Sequence[NDArray[np.float64]] | None = None, 401 *, 402 data: Any | None = None, 403 alpha: float = 0.05, 404 fdr: bool = True, 405 seed: int = 1, 406 ci: str = "parcorr", 407 max_cond_size: int = 2, 408 threads: int = 1, 409) -> DiscoveryResult: 410 n, cols = _coerce_tabular(names, columns, data=data) 411 return _discover_rfci( 412 n, 413 cols, 414 alpha=alpha, 415 fdr=fdr, 416 seed=seed, 417 ci=ci, 418 max_cond_size=max_cond_size, 419 threads=threads, 420 )
423def discover_pcmci( 424 names: Any | None = None, 425 columns: Sequence[NDArray[np.float64]] | None = None, 426 *, 427 data: Any | None = None, 428 max_lag: int = 1, 429 alpha: float = 0.05, 430 fdr: bool = True, 431 seed: int = 1, 432 ci: str = "parcorr", 433 weights: list[float] | None = None, 434 threads: int = 1, 435) -> DiscoveryResult: 436 n, cols = _coerce_tabular(names, columns, data=data) 437 return _discover_pcmci( 438 n, 439 cols, 440 max_lag=max_lag, 441 alpha=alpha, 442 fdr=fdr, 443 seed=seed, 444 ci=ci, 445 weights=weights, 446 threads=threads, 447 )
450def discover_pcmci_plus( 451 names: Any | None = None, 452 columns: Sequence[NDArray[np.float64]] | None = None, 453 *, 454 data: Any | None = None, 455 max_lag: int = 1, 456 alpha: float = 0.05, 457 fdr: bool = True, 458 seed: int = 1, 459 ci: str = "parcorr", 460 weights: list[float] | None = None, 461 threads: int = 1, 462) -> DiscoveryResult: 463 n, cols = _coerce_tabular(names, columns, data=data) 464 return _discover_pcmci_plus( 465 n, 466 cols, 467 max_lag=max_lag, 468 alpha=alpha, 469 fdr=fdr, 470 seed=seed, 471 ci=ci, 472 weights=weights, 473 threads=threads, 474 )
541def discover_rpcmci( 542 names: Any | None = None, 543 columns: Sequence[NDArray[np.float64]] | None = None, 544 *, 545 data: Any | None = None, 546 regimes: Sequence[int], 547 max_lag: int = 1, 548 alpha: float = 0.05, 549 fdr: bool = True, 550 seed: int = 1, 551 ci: str = "parcorr", 552 weights: list[float] | None = None, 553 threads: int = 1, 554) -> RpcmciDiscoverySummary: 555 """Run RPCMCI. ``regimes`` is required (length = series length); no silent half-split. 556 557 Call ``two_regime_half_split(len(series))`` for an explicit two-regime mid-point split. 558 """ 559 n, cols = _coerce_tabular(names, columns, data=data) 560 return _discover_rpcmci( 561 n, 562 cols, 563 regimes=list(regimes), 564 max_lag=max_lag, 565 alpha=alpha, 566 fdr=fdr, 567 seed=seed, 568 ci=ci, 569 weights=weights, 570 threads=threads, 571 )
Run RPCMCI. regimes is required (length = series length); no silent half-split.
Call two_regime_half_split(len(series)) for an explicit two-regime mid-point split.
614def discover_structure_mcmc( 615 names: Any | None = None, 616 columns: Sequence[NDArray[np.float64]] | None = None, 617 *, 618 data: Any | None = None, 619 n_chains: int = 4, 620 n_warmup: int = 500, 621 n_draws: int = 1000, 622 thin: int = 1, 623 seed: int = 1, 624 threads: int = 1, 625) -> GraphPosterior: 626 n, cols = _coerce_tabular(names, columns, data=data) 627 return _discover_structure_mcmc( 628 n, 629 cols, 630 n_chains=n_chains, 631 n_warmup=n_warmup, 632 n_draws=n_draws, 633 thin=thin, 634 seed=seed, 635 threads=threads, 636 )
736def cpdag_oriented_edges(cpdag: "Cpdag", *, require_oriented: bool = True) -> list[tuple[str, str]]: 737 """Return directed edges from a CPDAG; error if undirected remain when required.""" 738 from .graph import Cpdag 739 740 if not isinstance(cpdag, Cpdag): 741 raise TypeError(f"expected Cpdag, got {type(cpdag)!r}") 742 directed: list[tuple[str, str]] = [] 743 undirected = 0 744 for src, tgt, kind in cpdag.edges(): 745 if kind == "directed": 746 directed.append((src, tgt)) 747 elif kind == "undirected": 748 undirected += 1 749 else: 750 undirected += 1 751 if undirected and require_oriented: 752 raise ValueError( 753 f"CPDAG has {undirected} undirected/ambiguous edge(s); orient before " 754 "PathSpecific/Interventional queries (require_oriented=True)" 755 ) 756 if require_oriented: 757 try: 758 dag = cpdag.try_into_dag() 759 except Exception as exc: # noqa: BLE001 760 raise ValueError( 761 "CPDAG is not fully oriented; cannot coerce to DAG for path/distribution queries" 762 ) from exc 763 return list(dag.edges()) 764 return directed
Return directed edges from a CPDAG; error if undirected remain when required.
221def discovery_to_dag(result: DiscoveryResult) -> "Dag": 222 """Build a ``Dag`` from a discovery result's directed ``graph_edges``. 223 224 Raises ``ValueError`` if any undirected/circle marks remain. 225 """ 226 from .graph import Dag 227 228 names: list[str] = [] 229 seen: set[str] = set() 230 directed: list[tuple[str, str]] = [] 231 for e in result.graph_edges: 232 for n in (e.source, e.target): 233 if n not in seen: 234 seen.add(n) 235 names.append(n) 236 if e.at_source == "tail" and e.at_target == "arrow": 237 directed.append((e.source, e.target)) 238 elif e.at_source == "arrow" and e.at_target == "tail": 239 directed.append((e.target, e.source)) 240 else: 241 raise ValueError( 242 f"cannot coerce edge {e.source}->{e.target} " 243 f"({e.at_source}/{e.at_target}) into a DAG; " 244 "use graph_edges or a CPDAG/PAG constructor" 245 ) 246 return Dag.from_edges(names, directed)
Build a Dag from a discovery result's directed graph_edges.
Raises ValueError if any undirected/circle marks remain.
727def graph_posterior_map_dag(post: "GraphPosterior") -> "Dag": 728 """MAP DAG from a graph posterior (maximum-weight atom).""" 729 from .graph import Dag 730 731 edges = graph_posterior_map_edges(post) 732 names = list(post.names) if post.names else sorted({a for e in edges for a in e}) 733 return Dag.from_edges(names, edges)
MAP DAG from a graph posterior (maximum-weight atom).
703def graph_posterior_map_edges(post: "GraphPosterior") -> list[tuple[str, str]]: 704 """Oriented edges from the maximum-weight adjacency mask in a graph posterior.""" 705 import numpy as np 706 707 if post.n_graphs < 1 or not post.weights: 708 raise ValueError("GraphPosterior has no graphs") 709 i = int(np.argmax(np.asarray(post.weights, dtype=np.float64))) 710 mask = int(post.adjacency[i]) 711 n = int(post.n_vars) 712 names = list(post.names) 713 if len(names) < n: 714 names = [f"x{j}" for j in range(n)] 715 edges: list[tuple[str, str]] = [] 716 for fr in range(n): 717 for to in range(n): 718 if fr == to: 719 continue 720 # edge_bit(n, from, to) = from*(n-1) + (to if to < from else to-1) 721 bit = fr * (n - 1) + (to if to < fr else to - 1) 722 if (mask >> bit) & 1: 723 edges.append((names[fr], names[to])) 724 return edges
Oriented edges from the maximum-weight adjacency mask in a graph posterior.
Two-regime half-split assignment (opt-in helper for RPCMCI; not applied by default).