Hidden common causes#

Cover: an iceberg whose two peaks join into one faint mass under the waterline, drawn in navy lines

A hidden common cause, or latent confounder, is an unmeasured variable that affects two measured variables. This release adds RCD and BottomUpParceLiNGAM as supported methods. With FCI, they cover data with hidden common causes. Below, we run all three on one small dataset and report benchmark results. Other updates make small fits several times faster.

Why hidden causes matter#

Many causal discovery methods, including PC, GES, and DirectLiNGAM, assume causal sufficiency: every common cause of two measured variables is measured too. Real data often violate this assumption. A hidden common cause can make two variables dependent even without an edge between them. A method that assumes causal sufficiency can then draw an edge that is not there. Methods that allow hidden common causes can mark the pair or leave the edge’s ends unresolved.

Methods#

We benchmark each supported method against another package and check its results on known graphs before each release. RCD and BottomUpParceLiNGAM are new in this release.

Method

Assumes

FCI

Linear relations with roughly Gaussian noise, through its Fisher-z test

RCD

Linear relations with non-Gaussian noise

BottomUpParceLiNGAM

Linear relations with non-Gaussian noise

Reading a PAG#

All three return a partial ancestral graph (PAG) over the variables you measured. Each end of an edge carries a tail, an arrowhead, or a circle. A circle means that end is unresolved.

  • x -> y: x causes y.

  • x <-> y: a hidden variable links x and y, such as a common cause.

  • x o-> y: y does not cause x; whether x causes y is unresolved.

  • x o-o y: both ends are unresolved.

FCI marks an end with a tail or an arrowhead only when every graph consistent with the data’s conditional independences agrees on that mark. RCD and BottomUpParceLiNGAM use the same marks for their findings: -> for a direct cause and <-> for a pair linked by a hidden variable. BottomUpParceLiNGAM also uses o-o between variables it cannot order. See Graph types for details.

Example#

Here h causes x0 and x1 but is not in the data. Also, x0 causes x3, and x1 causes x2. The noise is uniform, so it is not Gaussian.

example.py#
import numpy as np
import pandas as pd

import andrey

# h is a common cause of x0 and x1, and it is not in the data.
rng = np.random.default_rng(0)
h, e0, e1, e2, e3 = rng.uniform(-1, 1, (5, 2000))
x0, x1 = 1.5 * h + e0, 1.5 * h + e1
df = pd.DataFrame({"x0": x0, "x1": x1, "x2": 0.6 * x1 + e2, "x3": 0.6 * x0 + e3})

methods = (andrey.fci, andrey.rcd, andrey.bottom_up_parce_lingam)
print(*(method(df) for method in methods), sep="\n\n")

# For comparison: DirectLiNGAM assumes every common cause is in the data.
print()
print(andrey.direct_lingam(df))

What each method returns

x0x1x2x3h
True graphh is hidden, not in the data
x0x1x2x3
FCIevery end unresolved
x0x1x2x3
RCDmatches the true graph
x0x1x2x3
BottomUpParceLiNGAMx0 and x1 left unordered
x0x1x2x3
DirectLiNGAMa wrong edge, x0 -> x1
Every node in the same place: h is hidden (dashed), and DirectLiNGAM's x0 -> x1 (amber) is not in the true graph.
  • RCD returns the true graph: x0 <-> x1 for the pair linked by h, and both direct edges.

  • BottomUpParceLiNGAM finds both direct edges. It cannot order x0 and x1, which share h, so it groups them in one block joined by o-o. In causal_order, this block appears as the nested list [0, 1].

  • FCI finds the three edges but leaves every end a circle. Its orientations rely on conditional independences. These four variables have the same independences as a graph with no hidden cause and no collider (a variable that two arrows point into), such as x3 <- x0 -> x1 -> x2, so the independences cannot settle any mark. Given an observed cause of x0 and another of x1, FCI can mark the pair x0 <-> x1.

RCD and BottomUpParceLiNGAM also test whether regression residuals are independent. With non-Gaussian noise, this test settles the directions in this example. RCD and BottomUpParceLiNGAM also estimate the edge weights: 0.598 and 0.61, compared with the true 0.6. DirectLiNGAM assumes causal sufficiency. On the same data, it returns x0 -> x1 (the last graph above), an edge absent from the true graph.

The script prints each result in full:

example.py#
FCI  pag  |  4 nodes  |  3 edges (3 circle)

  x0 o-o x1
  x0 o-o x3
  x1 o-o x2

RCD  pag  |  4 nodes  |  3 edges (1 bidirected, 2 directed)

  x0 <-> x1
  x0 -> x3  (+0.598)
  x1 -> x2  (+0.61)

  confounded_pairs: [[0, 1]]

BottomUpParceLiNGAM  pag  |  4 nodes  |  3 edges (1 circle, 2 directed)

  x0 o-o x1
  x0 -> x3  (+0.598)
  x1 -> x2  (+0.61)

  causal_order: [[0, 1], 3, 2]

DirectLiNGAM  dag  |  4 nodes  |  3 edges (3 directed)

  x0 -> x1  (+0.686)
  x0 -> x3  (+0.598)
  x1 -> x2  (+0.61)

  order: x0 -> x1 -> x2 -> x3

Benchmarks#

We compared the three supported methods with packages that implement them.

Method

Compared with

Variables

Andrey’s speedup

Same graph

RCD

lingam

4-24 observed

1.7-2.9x

25 of 25 datasets

BottomUpParceLiNGAM

lingam

4-24 observed

2.5-10.8x

25 of 25 datasets

FCI

causal-learn

20-800, hidden ones included

3.4-16.5x at 50 to 800; 0.62x at 20

13 of 18 datasets

The 0.2.0 release card reports measurements for RCD and BottomUpParceLiNGAM on linear data with uniform noise, one node in five hidden, and 2,000 rows, on 2 cores.

Speedup over lingamRelease card
Method
4 observed variables1.66x
8 observed variables2.35x
12 observed variables2.17x
16 observed variables2.88x
24 observed variables2.55x
4 observed variables2.48x
8 observed variables5.08x
12 observed variables5.68x
16 observed variables6.61x
24 observed variables10.8x

Each bar is lingam's time divided by Andrey's on the same dataset, the median over five datasets, on 2 cores. Each method's bars share its own scale.

Agreement between packages does not establish accuracy: from 8 observed variables, BottomUpParceLiNGAM’s directed edges have more errors than the empty graph’s, in both packages.

Directed-edge errors

Andrey and lingamEmpty graphMore errors than the empty graph

RCDAt or below the empty graph's errors at every size
01020304048121624observed variablesEmpty graph, 4 observed variables: 3 errorsEmpty graph, 8 observed variables: 4 errorsEmpty graph, 12 observed variables: 8 errorsEmpty graph, 16 observed variables: 11 errorsEmpty graph, 24 observed variables: 19 errors19Andrey and lingam, 4 observed variables: 0 errorsAndrey and lingam, 8 observed variables: 4 errorsAndrey and lingam, 12 observed variables: 6 errorsAndrey and lingam, 16 observed variables: 8 errorsAndrey and lingam, 24 observed variables: 11 errors11
BottomUpParceLiNGAMMore errors than the empty graph from 8 observed variables
01020304048121624observed variablesEmpty graph, 4 observed variables: 3 errorsEmpty graph, 8 observed variables: 4 errorsEmpty graph, 12 observed variables: 8 errorsEmpty graph, 16 observed variables: 11 errorsEmpty graph, 24 observed variables: 19 errors19Andrey and lingam, 4 observed variables: 1 errorsAndrey and lingam, 8 observed variables: 11 errorsAndrey and lingam, 12 observed variables: 17 errorsAndrey and lingam, 16 observed variables: 22 errorsAndrey and lingam, 24 observed variables: 40 errors40
The median over five datasets per size; Andrey and lingam return the same graphs, so they share one line. Lower is better.

FCI’s numbers come from the 0.1.0 release card: linear-Gaussian data with one variable in ten hidden and ten rows per variable, on 16 cores. At 20 variables, Andrey is slower, but both packages take under 0.1 s. Their median endpoint errors are within two of each other. At 800 variables, both have more endpoint errors than the empty graph.

Speed and accuracy depend on your data, settings, and hardware.

Other highlights#

  • Faster small fits. PC, FCI, GES, and hill climbing now find the Basic Linear Algebra Subprograms (BLAS) libraries once per process instead of on every fit. At 10 variables, PC and FCI take about a twelfth of the time they took before, hill climbing an eighth, and GES two fifths. Larger fits are no slower, and the graphs are unchanged.

  • Release cards. The Release cards page has one card per release: its benchmarks, the methods it adds or promotes, their known limits, and how to rerun the benchmarks.

  • Limitations. Every method’s docstring has a Limitations paragraph stating where the method was measured to work or that it is not benchmarked yet.

  • Checks before the fit. Every method checks your data and settings before fitting. Data with NaN or infinity, a constant column, or fewer than 2 rows raises a ValueError that names the argument. An out-of-range setting raises the same error.

For more details, read the changelog.

Try it#

pip install -U andrey-core

Two new notebooks fit RCD and BottomUpParceLiNGAM on the example’s model, read the edge weights, and check the result over ten datasets. You can open each notebook in Colab, then try the methods on your own data.

Help us build it#

Andrey is in alpha, and the API may change between releases. When you try it on your own data, tell us what breaks or which method you need. We also welcome pull requests. The contributing guide explains how to set up.