"""Facade adapters of the continuous-optimization learners: NOTEARS and DAGMA.
``notears`` and ``dagma`` share :mod:`andrey.search.continuous`: a loss over a weight matrix, a
sparsity penalty, and an acyclicity function, minimized from the zero matrix. ``robust=True``
follows the first fit with the likelihood path of Deng et al. (2024).
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from andrey.api._adapt import (
column_labels,
copies_named,
data_matrix,
json_safe,
structure_output,
)
from andrey.api._method import method_spec
from andrey.core.backend import one_blas_thread
from andrey.core.knowledge import Knowledge, resolve
if TYPE_CHECKING:
import numpy.typing as npt
from andrey.core import StructureOutput
from andrey.search.continuous import ContinuousResult
def _output(result: ContinuousResult, labels: tuple[str, ...] | None) -> StructureOutput:
"""Wrap a fit as a weighted DAG with its bookkeeping."""
return structure_output(
result.dag,
labels=labels,
weighted_adjacency=result.weighted_adjacency,
metadata={
"h_final": json_safe(result.h_final),
"edges_removed": result.edges_removed,
"path_steps": result.path_steps,
},
)
[docs]
@method_spec("notears")
def notears(
data: npt.ArrayLike,
*,
lambda1: float = 0.1,
w_threshold: float = 0.3,
max_iter: int = 100,
h_tol: float = 1e-8,
rho_max: float = 1e16,
robust: bool = False,
mcp_lambda: float = 0.4,
mcp_delta: float = 0.2,
mcp_decay: float = 0.8,
max_path: int = 50,
knowledge: Knowledge | None = None,
) -> StructureOutput:
"""Learn a weighted DAG with NOTEARS, a continuous-optimization structure learner.
NOTEARS [1]_ minimizes the least-squares loss of a linear model plus an ``l1`` penalty on the
weights, under the constraint that the weight matrix is acyclic, written as the smooth equation
``tr(exp(W * W)) - d = 0``. With ``robust=True`` the fit is followed by the likelihood path of
[2]_, which replaces the loss by the Gaussian likelihood and the penalty by the quasi-MCP.
Parameters
----------
data : array-like of shape (n_samples, n_variables)
Observed data, one row per sample. Coerced to ``float64``. A DataFrame's column names
become the structure's ``labels``.
lambda1 : float, default=0.1
Weight of the ``l1`` penalty of the first fit, at least ``0``. Larger values give sparser
graphs.
w_threshold : float, default=0.3
An edge is kept when its weight magnitude reaches this value, at least ``0``.
max_iter : int, default=100
Most dual steps of the augmented Lagrangian, at least ``1``. Stopping on it rather than on
``h_tol`` or ``rho_max`` warns with :class:`~andrey.SearchLimitWarning`.
h_tol : float, default=1e-8
The fit stops when the acyclicity function falls to this value, above ``0``.
rho_max : float, default=1e16
The fit stops when the penalty weight on the acyclicity function reaches this value, above
``1``, the weight the fit starts from.
robust : bool, default=False
``True`` follows the first fit with the likelihood path of [2]_ (see Notes).
mcp_lambda : float, default=0.4
The penalty level of the first step of the path, above ``0``. Used only with ``robust``.
mcp_delta : float, default=0.2
The weight magnitude above which the quasi-MCP penalty stops growing, above ``0``, at the
first step of the path. Used only with ``robust``.
mcp_decay : float, default=0.8
The factor, in ``(0, 1)``, by which ``mcp_lambda`` and ``mcp_delta`` shrink after each
accepted step. Used only with ``robust``.
max_path : int, default=50
Most accepted steps of the path, at least ``1``. Used only with ``robust``. Reaching it
warns with :class:`~andrey.SearchLimitWarning`.
knowledge : Knowledge or None, default=None
Background knowledge, a :class:`~andrey.Knowledge`: required and forbidden edges, the pairs
allowed to be adjacent, and temporal tiers, all honored. Forbidden edges, edges into an
earlier tier, and pairs outside the allowed set are held at zero; a required edge is left
out of the penalty and the threshold and is never removed by the cycle repair.
Returns
-------
StructureOutput
Wraps a ``dag`` :class:`~andrey.core.GraphStructure`; ``weighted_adjacency[i, j]`` is the
coefficient of edge ``i -> j``. ``metadata`` holds ``"algorithm"`` (``"notears"``),
``"h_final"`` (the acyclicity function of the fit before the threshold),
``"edges_removed"`` (edges the threshold kept that the cycle repair dropped), and
``"path_steps"`` (accepted steps of the path, ``0`` without ``robust``).
Raises
------
TypeError
If ``knowledge`` is neither ``None`` nor a :class:`~andrey.Knowledge`.
ValueError
If an argument has the wrong type, lies outside its range, or is not one of its choices; the
message names the method, the argument, what it accepts, and the value. Also if ``data`` is
not a finite numeric matrix of two or more rows without a constant column, or if
``knowledge`` names a column ``data`` lacks or contradicts itself, as
:class:`~andrey.Knowledge` lists.
Warns
-----
ExperimentalWarning
On the first call in a process: the method has no published benchmark, and its API may
change without deprecation.
Notes
-----
**When to use.** NOTEARS suits i.i.d. continuous observational data from a linear structural
equation model, in which each variable is a weighted sum of its causes plus noise, with no
unmeasured common causes [1]_. Its default least-squares fit reads the columns in their given
units, so its graph can depend on how each column is scaled [4]_ [5]_; ``robust=True`` follows
it with the Gaussian-likelihood path of [2]_. Its ``l1`` penalty favors sparse graphs, and its
acyclicity function takes a matrix exponential of the full weight matrix, so it suits a
moderate number of variables; it returns a DAG with the coefficient of each edge. With
nonlinear effects, use :func:`~andrey.cam`; when the noise is non-Gaussian and every edge
should be oriented, use :func:`~andrey.direct_lingam`; when a CPDAG is enough on
linear-Gaussian data, use :func:`~andrey.ges`.
The first fit writes the weights as ``W = W+ - W-`` with both parts non-negative and solves the
augmented Lagrangian ``loss + 0.5 * rho * h**2 + alpha * h + lambda1 * sum(W+ + W-)`` with
L-BFGS-B, where ``loss = 0.5 * tr((I - W).T @ S @ (I - W))`` for the sample covariance ``S``.
After each solve ``rho`` grows tenfold while ``h`` has not fallen to a quarter of its previous
value, and then ``alpha += rho * h``, until ``h <= h_tol`` or ``rho >= rho_max``. Edges below
``w_threshold`` are removed, and then the weakest remaining edges until no cycle is left.
With ``robust=True`` the thresholded weights start a path of refits [2]_. Each step minimizes
the same augmented Lagrangian with the Gaussian negative log-likelihood, with one noise variance
per variable and up to a constant,
``0.5 * sum_j log(((I - W).T @ S @ (I - W))[j, j]) - log|det(I - W)|``,
in place of the loss, and with the quasi-MCP in place of the ``l1`` penalty. The quasi-MCP of an
entry ``t`` is ``lam * (|t| - t**2 / (2 * delta))`` for ``|t| <= delta`` and ``lam * delta / 2``
above it. Adam [3]_ with step size ``3e-4`` solves each inner problem in place of L-BFGS-B. A
step starts from the last accepted weights. Its result is thresholded at ``w_threshold`` and
its cycles repaired, and the step is accepted when the likelihood of that graph is lower than
the last accepted one's by more than a ``1e-6`` fraction. ``lam`` and ``delta`` then shrink by
``mcp_decay``, and the path ends at the first step that does not lower the likelihood. [2]_
shows that, with a small enough penalty, the global minimizers of the penalized likelihood have
the same DAG structures for the raw and the standardized data; the path finds a local solution,
and the cost of a step is the number of its Adam iterations times a matrix exponential.
Ties between equal weights go to the column whose rank, computed from the data, comes
first, as for :func:`~andrey.hc`, so the result does not depend on the column order unless two
columns are identical. The :doc:`column-order guide </docs/guides/column-order>` lists what
reordering the columns does to each method. The fit draws no random numbers and runs on one BLAS
thread, so the host's thread count does not change it.
**Missing values.** A ``NaN`` in ``data`` raises ``ValueError``: the loss fits every variable
over the same rows, so a row with a missing value has no place in it. Drop the incomplete rows
first, which suits values missing completely at random, or use :func:`~andrey.pc` or
:func:`~andrey.fci`, whose tests delete rows per test.
**Limitations.** Measured on simulated linear data from sparse random graphs (Erdos-Renyi and
scale-free, mean degree 2) with Gaussian and uniform noise, 1,000 rows, and 10, 20, 50 and 100
variables, five graphs for each combination, at the default settings. Errors are SHD against the
true CPDAG, medians over those 20 fits; the empty graph's are 9.5, 19.5, 49.5 and 99.5. The
least-squares fit changes when the columns are rescaled or standardized [4]_ [5]_, and [6]_
traces this to the score. With a small enough penalty, the global minimizers of the penalized
likelihood that ``robust=True`` uses have the same graph on raw and standardized columns [2]_.
The robust fit, though, starts from the weights of the least-squares fit, and its penalty and
threshold act on the weights in data units; rescaling the columns by powers of two, all by one
factor or each by its own, changed its graph in every fit tried at 10 and 20 variables. Both
that starting point and the penalty and threshold on the weights in data units are the published
method's [2]_. The optimization is also harder on standardized data, and more so as the number
of variables grows [2]_. On standardized columns the default fit's median errors were 7, 18.5,
44.5 and 92 at those sizes, below the empty graph's in 61 of the 80 fits; on
``scale="unitless"`` data of ``andrey.data.sample_scm`` they were 8, 17, 43.5 and 89, below it
in 78 of 80. With ``robust=True`` they were 10, 14.5 and 30.5 on standardized columns and 6.5,
14.5 and 32.5 on unitless data at 10, 20 and 50 variables; at 50 variables one graph per
combination was fit (four fits), and the one fit tried at 100 variables did not finish within
900 s, so there is no figure for it. On the same data, ``ges`` had median errors of 2, 4, 19.5
and 79 on standardized columns and 3, 7.5, 29 and 90 on unitless data, and ``calm`` 3.5, 6, 18
and 36, and 4, 12, 31.5 and 55. The default fit's median time was 0.6 s at 10 variables and 54 s
at 100 on standardized columns (0.4 s and 15.5 s on unitless data), on one core; with
``robust=True`` it was 23 s at 10 variables and 198 s at 50 on standardized columns (5.3 s and
69 s on unitless data), because each Adam iteration of a path step computes a matrix
exponential. A fit can stop at a local optimum, and only weights that reach ``w_threshold``
become edges. Nothing was measured beyond 100 variables, at other densities, row counts or noise
types, or on non-linear data. The numbers are a reference, not a hard limit: they change with
the data generator, the density, the method's settings, and the machine.
References
----------
.. [1] Zheng, X., Aragam, B., Ravikumar, P., and Xing, E. P. (2018). DAGs with NO TEARS:
Continuous optimization for structure learning. NeurIPS 31, 9472-9483.
.. [2] Deng, C., Bello, K., Ravikumar, P., and Aragam, B. (2024). Markov equivalence and
consistency in differentiable structure learning. NeurIPS 37.
.. [3] Kingma, D. P. and Ba, J. (2015). Adam: A method for stochastic optimization. ICLR 2015.
.. [4] Kaiser, M. and Sipos, M. (2022). Unsuitability of NOTEARS for causal graph discovery
when dealing with dimensional quantities. Neural Processing Letters 54(3), 1587-1595.
.. [5] Reisach, A. G., Seiler, C., and Weichwald, S. (2021). Beware of the simulated DAG!
Causal discovery benchmarks may be easy to game. NeurIPS 34.
.. [6] Ng, I., Huang, B., and Zhang, K. (2024). Structure learning with continuous
optimization: A sober look and beyond. CLeaR 2024, PMLR 236, 71-105.
Examples
--------
>>> import warnings
>>> import numpy as np
>>> from andrey import notears
>>> rng = np.random.default_rng(0)
>>> x = rng.standard_normal(500)
>>> y = x + 0.5 * rng.standard_normal(500)
>>> with warnings.catch_warnings():
... warnings.simplefilter("ignore")
... out = notears(np.column_stack([x, y]))
>>> out.structure.kind
'dag'
"""
from andrey.search.continuous import notears as _notears
X = data_matrix(data, method="notears")
labels = column_labels(data)
constraints = resolve(knowledge, X.shape[1], labels, method="notears")
with copies_named(labels), one_blas_thread():
result = _notears(
X,
lambda1=lambda1,
w_threshold=w_threshold,
max_iter=max_iter,
h_tol=h_tol,
rho_max=rho_max,
robust=robust,
mcp_lambda=mcp_lambda,
mcp_delta=mcp_delta,
mcp_decay=mcp_decay,
max_path=max_path,
labels=labels,
constraints=constraints,
)
return _output(result, labels)
[docs]
@method_spec("dagma")
def dagma(
data: npt.ArrayLike,
*,
lambda1: float = 0.03,
w_threshold: float = 0.3,
stages: int = 5,
warm_iter: int = 30000,
max_iter: int = 60000,
lr: float = 3e-4,
robust: bool = False,
mcp_lambda: float = 0.4,
mcp_delta: float = 0.2,
mcp_decay: float = 0.8,
max_path: int = 50,
knowledge: Knowledge | None = None,
) -> StructureOutput:
"""Learn a weighted DAG with DAGMA, a continuous-optimization structure learner.
DAGMA [1]_ minimizes the least-squares loss of a linear model plus an ``l1`` penalty on the
weights, with the acyclicity function ``-log det(s * I - W * W) + d * log(s)`` as a barrier
that is lowered along a central path. With ``robust=True`` the fit is followed by the
likelihood path of [2]_, which replaces the loss by the Gaussian likelihood and the penalty by
the quasi-MCP.
Parameters
----------
data : array-like of shape (n_samples, n_variables)
Observed data, one row per sample. Coerced to ``float64``. A DataFrame's column names
become the structure's ``labels``.
lambda1 : float, default=0.03
Weight of the ``l1`` penalty of the first fit, at least ``0``. Larger values give sparser
graphs.
w_threshold : float, default=0.3
An edge is kept when its weight magnitude reaches this value, at least ``0``.
stages : int, default=5
Stages of the central path, at least ``1`` (``T`` in the paper). Stage ``t`` weights the
loss by ``0.1**t`` and uses ``s`` from ``(1, 0.9, 0.8, 0.7, 0.6)``, the last value
repeating.
warm_iter : int, default=30000
Most Adam steps of each stage but the last, at least ``1``.
max_iter : int, default=60000
Most Adam steps of the last stage, at least ``1``. A last stage that takes them all
without converging warns with :class:`~andrey.SearchLimitWarning`.
lr : float, default=3e-4
Step size of Adam, above ``0``.
robust : bool, default=False
``True`` follows the first fit with the likelihood path of [2]_ (see Notes).
mcp_lambda : float, default=0.4
The penalty level of the first step of the path, above ``0``. Used only with ``robust``.
mcp_delta : float, default=0.2
The weight magnitude above which the quasi-MCP penalty stops growing, above ``0``, at the
first step of the path. Used only with ``robust``.
mcp_decay : float, default=0.8
The factor, in ``(0, 1)``, by which ``mcp_lambda`` and ``mcp_delta`` shrink after each
accepted step. Used only with ``robust``.
max_path : int, default=50
Most accepted steps of the path, at least ``1``. Used only with ``robust``. Reaching it
warns with :class:`~andrey.SearchLimitWarning`.
knowledge : Knowledge or None, default=None
Background knowledge, a :class:`~andrey.Knowledge`: required and forbidden edges, the pairs
allowed to be adjacent, and temporal tiers, all honored. Forbidden edges, edges into an
earlier tier, and pairs outside the allowed set are held at zero; a required edge is left
out of the penalty and the threshold and is never removed by the cycle repair.
Returns
-------
StructureOutput
Wraps a ``dag`` :class:`~andrey.core.GraphStructure`; ``weighted_adjacency[i, j]`` is the
coefficient of edge ``i -> j``. ``metadata`` holds ``"algorithm"`` (``"dagma"``),
``"h_final"`` (the log-determinant acyclicity function at ``s = 1`` before the threshold),
``"edges_removed"`` (edges the threshold kept that the cycle repair dropped), and
``"path_steps"`` (accepted steps of the path, ``0`` without ``robust``).
Raises
------
TypeError
If ``knowledge`` is neither ``None`` nor a :class:`~andrey.Knowledge`.
ValueError
If an argument has the wrong type, lies outside its range, or is not one of its choices; the
message names the method, the argument, what it accepts, and the value. Also if ``data`` is
not a finite numeric matrix of two or more rows without a constant column, or if
``knowledge`` names a column ``data`` lacks or contradicts itself, as
:class:`~andrey.Knowledge` lists.
Warns
-----
ExperimentalWarning
On the first call in a process: the method has no published benchmark, and its API may
change without deprecation.
Notes
-----
**When to use.** DAGMA suits i.i.d. continuous observational data from a linear structural
equation model, in which each variable is a weighted sum of its causes plus noise, with no
unmeasured common causes [1]_. Its default least-squares fit reads the columns in their given
units, so its graph can depend on how each column is scaled [4]_ [5]_; ``robust=True`` follows
it with the Gaussian-likelihood path of [2]_. Its ``l1`` penalty favors sparse graphs, and its
acyclicity function takes a log-determinant of the full weight matrix, so it suits a moderate
number of variables; it returns a DAG with the coefficient of each edge. With nonlinear
effects, use :func:`~andrey.cam`; when the noise is non-Gaussian and every edge should be
oriented, use :func:`~andrey.direct_lingam`; when a CPDAG is enough on linear-Gaussian data,
use :func:`~andrey.ges`.
The first fit starts from the zero matrix. Stage ``t`` of the central path runs Adam [3]_
(betas ``0.99`` and ``0.999``) on ``mu * (loss + lambda1 * |W|_1) + h(W)`` with
``loss = 0.5 * tr((I - W).T @ S @ (I - W))`` for the sample covariance ``S`` and
``mu = 0.1**t``, and stops after ``warm_iter`` steps (``max_iter`` in the last stage), or
earlier when, at a multiple of 1000 steps, the objective has changed by at most a ``1e-6``
fraction since the last such step. A step that leaves the domain where ``s * I - W * W`` is an
M-matrix is undone and the step size halved; a stage that leaves it at its first step starts
again with half the step size and ``s`` raised by ``0.1``. The diagonal of ``W`` is held at
zero. Edges below ``w_threshold`` are then removed, and then the weakest remaining edges until
no cycle is left.
With ``robust=True`` the thresholded weights start a path of refits [2]_. Each step runs the
central path again from the last accepted weights, with the Gaussian negative log-likelihood,
with one noise variance per variable and up to a constant,
``0.5 * sum_j log(((I - W).T @ S @ (I - W))[j, j]) - log|det(I - W)|``,
in place of the loss, and the quasi-MCP in place of the ``l1`` penalty. The quasi-MCP of an
entry ``t`` is ``lam * (|t| - t**2 / (2 * delta))`` for ``|t| <= delta`` and ``lam * delta / 2``
above it. The result of a step is thresholded at ``w_threshold`` and its cycles repaired, and
the step is accepted when the likelihood of that graph is lower than the last accepted one's by
more than a ``1e-6`` fraction. ``lam`` and ``delta`` then shrink by ``mcp_decay``, and the path
ends at the first step that does not lower the likelihood. [2]_ shows that, with a small enough
penalty, the global minimizers of the penalized likelihood have the same DAG structures for the
raw and the standardized data; the path finds a local solution, and each step repeats the whole
central path.
Ties between equal weights go to the column whose rank, computed from the data, comes
first, as for :func:`~andrey.hc`, so the result does not depend on the column order unless two
columns are identical. The :doc:`column-order guide </docs/guides/column-order>` lists what
reordering the columns does to each method. The fit draws no random numbers and runs on one BLAS
thread, so the host's thread count does not change it.
**Missing values.** A ``NaN`` in ``data`` raises ``ValueError``: the loss fits every variable
over the same rows, so a row with a missing value has no place in it. Drop the incomplete rows
first, which suits values missing completely at random, or use :func:`~andrey.pc` or
:func:`~andrey.fci`, whose tests delete rows per test.
**Limitations.** Measured on simulated linear data from sparse random graphs (Erdos-Renyi and
scale-free, mean degree 2) with Gaussian and uniform noise, 1,000 rows, and 10, 20, 50 and 100
variables, five graphs for each combination, at the default settings. Errors are SHD against the
true CPDAG, medians over those 20 fits; the empty graph's are 9.5, 19.5, 49.5 and 99.5. The
least-squares fit changes when the columns are rescaled or standardized [4]_ [5]_, and [6]_
traces this to the score. With a small enough penalty, the global minimizers of the penalized
likelihood that ``robust=True`` uses have the same graph on raw and standardized columns [2]_.
The robust fit, though, starts from the weights of the least-squares fit, and its penalty and
threshold act on the weights in data units; rescaling the columns by powers of two, all by one
factor or each by its own, changed its graph in every fit tried at 10 and 20 variables. Both
that starting point and the penalty and threshold on the weights in data units are the published
method's [2]_. The optimization is also harder on standardized data, and more so as the number
of variables grows [2]_. On standardized columns the default fit's median errors were 12, 25.5,
64 and 129.5 at those sizes, below the empty graph's in 7 of the 80 fits; on
``scale="unitless"`` data of ``andrey.data.sample_scm`` they were 9.5, 19, 49.5 and 99.5, below
it in 38 of 80. With ``robust=True`` they were 12, 25, 65 and 135.5 on standardized columns
(below the empty graph's in 10 of 80 fits) and 9.5, 18, 48 and 98.5 on unitless data (44 of 80).
On the same data, ``ges`` had median errors of 2, 4, 19.5 and 79 on standardized columns and 3,
7.5, 29 and 90 on unitless data, and ``calm`` 3.5, 6, 18 and 36, and 4, 12, 31.5 and 55. The
default fit's median time was 2.2 s at 10 variables and 28 s at 100 on standardized columns (1.8
s and 21 s on unitless data), on one core; with ``robust=True`` it was 21 s at 10 variables and
212 s at 100 on standardized columns (27 s and 310 s on unitless data), because each path step
repeats the whole central path. A fit can stop at a local optimum, and only weights that reach
``w_threshold`` become edges. Nothing was measured beyond 100 variables, at other densities, row
counts or noise types, or on non-linear data. The numbers are a reference, not a hard limit:
they change with the data generator, the density, the method's settings, and the machine.
References
----------
.. [1] Bello, K., Aragam, B., and Ravikumar, P. (2022). DAGMA: Learning DAGs via M-matrices and
a log-determinant acyclicity characterization. NeurIPS 35, 8226-8239.
.. [2] Deng, C., Bello, K., Ravikumar, P., and Aragam, B. (2024). Markov equivalence and
consistency in differentiable structure learning. NeurIPS 37.
.. [3] Kingma, D. P. and Ba, J. (2015). Adam: A method for stochastic optimization. ICLR 2015.
.. [4] Kaiser, M. and Sipos, M. (2022). Unsuitability of NOTEARS for causal graph discovery
when dealing with dimensional quantities. Neural Processing Letters 54(3), 1587-1595.
.. [5] Reisach, A. G., Seiler, C., and Weichwald, S. (2021). Beware of the simulated DAG!
Causal discovery benchmarks may be easy to game. NeurIPS 34.
.. [6] Ng, I., Huang, B., and Zhang, K. (2024). Structure learning with continuous
optimization: A sober look and beyond. CLeaR 2024, PMLR 236, 71-105.
Examples
--------
>>> import warnings
>>> import numpy as np
>>> from andrey import dagma
>>> rng = np.random.default_rng(0)
>>> x = rng.standard_normal(500)
>>> y = x + 0.5 * rng.standard_normal(500)
>>> with warnings.catch_warnings():
... warnings.simplefilter("ignore")
... out = dagma(np.column_stack([x, y]))
>>> out.structure.kind
'dag'
"""
from andrey.search.continuous import dagma as _dagma
X = data_matrix(data, method="dagma")
labels = column_labels(data)
constraints = resolve(knowledge, X.shape[1], labels, method="dagma")
with copies_named(labels), one_blas_thread():
result = _dagma(
X,
lambda1=lambda1,
w_threshold=w_threshold,
stages=stages,
warm_iter=warm_iter,
max_iter=max_iter,
lr=lr,
robust=robust,
mcp_lambda=mcp_lambda,
mcp_delta=mcp_delta,
mcp_decay=mcp_decay,
max_path=max_path,
labels=labels,
constraints=constraints,
)
return _output(result, labels)