Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 84 additions & 9 deletions causalts/algorithms/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,55 @@

"""Algorithm plugin registry for causal-ts.

Third-party algorithms can register themselves with :func:`register_algorithm`
so they are automatically available in the CLI without editing the package::
Third-party algorithms register a discovery function under a short name and then
work anywhere a built-in does -- :func:`run_algorithm`, ``list_algorithms()``, and
``causal-ts discover --algorithm <name>``.

The function must accept the calling convention :func:`run_algorithm` uses --
``fn(df=..., ci_test=..., max_lag=..., **kwargs)`` -- and return a
:class:`~causalts.result.CausalResult` subclass::

from causalts import register_algorithm, CausalResult

@register_algorithm("my_algo")
def run_my_algo(df, ci_test, max_lag, **kwargs):
...
return MyResult(cg_tig=..., var_names=..., _df=df)

After the import, ``causal-ts discover --algorithm my_algo data.csv`` works.
return MyResult(graph, df, list(df.columns))

``@register_algorithm`` only registers within the running process, so it covers
the Python API and any script that imports your module. To reach the installed
``causal-ts`` command -- a separate process that never imports your code -- also
advertise it in your own distribution's ``pyproject.toml``::

[project.entry-points."causalts.algorithms"]
my_algo = "my_package.plugin:run_my_algo"

Entry points are discovered on first use (see
:func:`_load_entry_point_algorithms`), so an installed plugin needs no import on
the caller's side.

**CLI limitations.** ``causal-ts discover --algorithm <plugin>`` forwards only
``df``/``ci_test``/``max_lag`` -- CDNOTS/CEDAR/GRACE-specific flags like
``--alpha``, ``--include-c``, or ``--max-degree`` are parsed but not passed
through, since they have no defined meaning for an arbitrary plugin. A plugin
that needs more configuration should read it from its own environment
variable, config file, or a separate CLI, and document that. ``--validate``
is also not supported for plugins -- the stability bootstrap only knows how
to re-run the built-in algorithms, so it is rejected outright rather than
silently reporting another algorithm's persistence values.
"""

from __future__ import annotations

import warnings

_ALGO_REGISTRY: dict[str, callable] = {}

#: Entry-point group third-party distributions advertise algorithms under.
ENTRY_POINT_GROUP = "causalts.algorithms"

_entry_points_loaded = False

# Algorithms whose module is expensive to import (GRACE pulls in
# pytorch-lightning) are registered lazily: name -> (module path, function).
# ``causalts.grace`` used to self-register at import time; registering here
Expand All @@ -41,6 +73,44 @@ def _resolve_lazy(name: str):
return fn


def _load_entry_point_algorithms() -> None:
"""Register algorithms advertised under the :data:`ENTRY_POINT_GROUP`.

Lets an installed third-party distribution reach the ``causal-ts`` command,
which runs in its own process and never imports the caller's modules. Runs
once, on first use, so plain ``import causalts`` pays nothing until an
algorithm is actually looked up.

A plugin that fails to import is warned about and skipped -- one broken
third-party package must not make the CLI unusable. Plugins never shadow a
built-in name.
"""
global _entry_points_loaded
if _entry_points_loaded:
return
_entry_points_loaded = True # set first: a raising plugin must not retry forever

from importlib.metadata import entry_points

try:
eps = entry_points(group=ENTRY_POINT_GROUP)
except Exception as exc: # pragma: no cover - defensive
warnings.warn(f"could not scan {ENTRY_POINT_GROUP!r} entry points: {exc}")
return

for ep in eps:
if ep.name in _ALGO_REGISTRY or ep.name in _LAZY_ALGO_REGISTRY:
warnings.warn(
f"algorithm plugin {ep.name!r} clashes with a built-in name and "
f"was ignored"
)
continue
try:
_ALGO_REGISTRY[ep.name] = ep.load()
except Exception as exc:
warnings.warn(f"could not load algorithm plugin {ep.name!r}: {exc}")


def register_algorithm(name: str):
"""Decorator to register a discovery function under *name*.

Expand All @@ -64,7 +134,12 @@ def decorator(fn):


def list_algorithms() -> list[str]:
"""Return sorted list of all registered algorithm names."""
"""Return sorted list of all registered algorithm names.

Includes installed entry-point plugins. ``causalts.cli`` calls this at import
time to build ``--algorithm``'s choices, so plugins appear there too.
"""
_load_entry_point_algorithms()
return sorted(set(_ALGO_REGISTRY) | set(_LAZY_ALGO_REGISTRY))


Expand Down Expand Up @@ -92,7 +167,7 @@ def run_algorithm(name: str, df, ci_test, max_lag, **kwargs):
if name in _LAZY_ALGO_REGISTRY:
_resolve_lazy(name)
else:
raise ValueError(
f"Unknown algorithm {name!r}. Available: {list_algorithms()}"
)
_load_entry_point_algorithms()
if name not in _ALGO_REGISTRY:
raise ValueError(f"Unknown algorithm {name!r}. Available: {list_algorithms()}")
return _ALGO_REGISTRY[name](df=df, ci_test=ci_test, max_lag=max_lag, **kwargs)
38 changes: 38 additions & 0 deletions causalts/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@
from .algorithms import list_algorithms as _list_algorithms # noqa: E402

ALGORITHM_CHOICES = _list_algorithms()
# Everything else in ALGORITHM_CHOICES is a third-party plugin (see .algorithms).
_BUILTIN_ALGORITHMS = ("cdnots", "cdnots+", "cedar", "grace", "grace-ss")
PLOT_FORMAT_CHOICES = ["png", "pdf", "svg"]
DATASET_CHOICES = ["ex1", "ex2", "ex3", "henon"]
LAG_SEL_CHOICES = ["partial_dcor", "dcor", "dcor_biased", "pearson", "lasso"]
Expand Down Expand Up @@ -480,6 +482,20 @@ def discover(
"check with --algorithm cdnots or cedar.",
param_hint="--validate",
)
if do_validate and algorithm not in _BUILTIN_ALGORITHMS:
# The bootstrap re-discovery closure below only special-cases
# cdnots/cdnots+ and otherwise re-runs CEDAR -- correct for the
# built-in fallback (nothing else reaches it), but wrong for a
# plugin: it would silently annotate the plugin's edges with CEDAR's
# persistence values instead of the plugin's own.
raise click.BadParameter(
f"--validate is not supported for third-party algorithm {algorithm!r} "
"(the stability bootstrap only knows how to re-run the built-in "
"algorithms). Re-run the stability check with --algorithm cdnots or "
"cedar, or bootstrap the plugin directly with "
"causalts.bootstrap.temporal_bootstrap.",
param_hint="--validate",
)
if want_pvalues and algorithm in ("grace", "grace-ss"):
raise click.BadParameter(
"--pvalues is not supported for GRACE (it emits continuous gate "
Expand Down Expand Up @@ -806,6 +822,28 @@ def discover(
f"GRACE-SS complete. Graph shape: {grace_ss_res.cg_tig.shape}, {n_edges} edges, {elapsed:.1f}s",
)

else:
# Third-party algorithm from the plugin registry. The built-ins get
# bespoke branches above because each saves extra artifacts (gate values,
# stability scores, ...); a plugin only has to return a CausalResult.
from .algorithms import run_algorithm

_log(ctx, f"Running {algorithm} (registered plugin)...")
plugin_res = run_algorithm(algorithm, df=df, ci_test=ci, max_lag=max_lag)
plugin_graph = np.asarray(plugin_res.cg_tig)
np.save(os.path.join(outdir, "estimated_graph.npy"), plugin_graph)
summary["output_files"]["graph"] = "estimated_graph.npy"

n_edges = int(plugin_graph.astype(bool).sum())
elapsed = _time.time() - t0
summary["elapsed_seconds"] = round(elapsed, 1)
summary["n_edges"] = n_edges
_log(
ctx,
f"{algorithm} complete. Graph shape: {plugin_graph.shape}, "
f"{n_edges} edges, {elapsed:.1f}s",
)

# Named edge list for interpretation (agent-legible). Load the graph back
# from disk so this works regardless of which algorithm branch ran.
_graph_path = os.path.join(outdir, "estimated_graph.npy")
Expand Down
45 changes: 28 additions & 17 deletions examples/custom_algorithm.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
"\n",
"1. **Subclass `CausalResult`** — set `cg_tig`, `var_names`, `_df`, `_scm_cache`.\n",
"2. **Decorate with `@register_algorithm(\"name\")`** — makes the algorithm available to\n",
" `list_algorithms()` and the `causal-ts discover --algorithm name` CLI.\n",
" `list_algorithms()` and `run_algorithm(...)` in-process. Add an entry point (Step 6)\n",
" to reach the `causal-ts` command as well.\n",
"3. **Return your result object** — you automatically inherit `.plot()`, `.estimate_effect()`,\n",
" `.fit_scm()`, `.counterfactual()`, `.attribute_anomaly()`, and all other DoWhy methods.\n",
"\n",
Expand Down Expand Up @@ -326,30 +327,40 @@
"source": [
"## Step 6 — CLI integration\n",
"\n",
"Once your module is imported, the CLI picks up your algorithm automatically.\n",
"No edits to causal-ts source code are required.\n",
"`@register_algorithm` registers within the **running process**, so it covers the Python\n",
"API and any script that imports your module:\n",
"\n",
"```python\n",
"# my_granger_plugin.py\n",
"from causalts import register_algorithm, CausalResult\n",
"...\n",
"@register_algorithm(\"granger\")\n",
"def run_granger(...): ...\n",
"from causalts.algorithms import run_algorithm\n",
"import my_granger_plugin # triggers registration\n",
"\n",
"res = run_algorithm(\"granger\", df=df, ci_test=None, max_lag=3)\n",
"```\n",
"\n",
"The installed `causal-ts` command is a **separate process** that never imports your code,\n",
"so the decorator alone cannot reach it. To make your algorithm available there, package\n",
"it and advertise the `causalts.algorithms` entry point in your own `pyproject.toml`:\n",
"\n",
"```toml\n",
"# my_granger_plugin/pyproject.toml\n",
"[project.entry-points.\"causalts.algorithms\"]\n",
"granger = \"my_granger_plugin.plugin:run_granger\"\n",
"```\n",
"\n",
"```bash\n",
"# In your shell session after importing your module:\n",
"pip install -e . # your plugin package\n",
"causal-ts discover data.csv --algorithm granger --max-lag 3\n",
"```\n",
"\n",
"Or from Python:\n",
"causal-ts discovers installed entry points on first use, so no import is needed on the\n",
"caller's side — `granger` simply appears in `--algorithm`, and `list_algorithms()`\n",
"reports it. A plugin that fails to import is warned about and skipped rather than\n",
"breaking the CLI, and it can never shadow a built-in name.\n",
"\n",
"```python\n",
"from causalts.algorithms import run_algorithm\n",
"import my_granger_plugin # triggers registration\n",
"\n",
"res = run_algorithm(\"granger\", df=df, ci_test=None, max_lag=3)\n",
"```"
"> **Signature contract.** The CLI calls your function as\n",
"> `fn(df=..., ci_test=..., max_lag=...)`, so it must accept those keyword names (extra\n",
"> options via `**kwargs`). Ignore `ci_test` if your method doesn't use one, as Granger\n",
"> does above.\n"
]
},
{
Expand Down Expand Up @@ -385,7 +396,7 @@
"- `result.estimate_effect(treatment, outcome, ...)` — DoWhy ATE\n",
"- `result.fit_scm()` / `result.counterfactual(...)` — structural causal model\n",
"- `result.attribute_anomaly(...)` — Shapley root cause attribution\n",
"- `causal-ts discover --algorithm my_algo data.csv` — CLI\n",
"- `causal-ts discover --algorithm my_algo data.csv` — CLI (once the entry point in Step 6 is declared)\n",
"\n",
"**Optional overrides:**\n",
"- `__repr__` — custom summary string\n",
Expand Down
Loading