Temporal causal discovery#

Cover: the moon over a swelling sea, its light an amber path on the waves, drawn in navy lines

Causes take time. Rain today slows today’s traffic, and today’s traffic shapes tomorrow’s sales. When you measure the same variables day after day, the order of the days tells you which way a link can run: the future cannot cause the past. As of 0.5.0, we support six methods that use this order: PCMCI, PCMCI+, LPCMCI, lasso Granger causality, VARMA-LiNGAM, and LongitudinalLiNGAM.

We explain what a time-series method finds, run two on a small example, and report benchmark results.

Supported methods#

Before each release, we benchmark each supported method against another package and check its results on known data. PCMCI, PCMCI+, LPCMCI, and Granger are new in this release. VARMA-LiNGAM and LongitudinalLiNGAM were experimental until now.

Method

Finds

Returns

PCMCI

Lagged links

One directed graph per lag

PCMCI+

Lagged links and links within a step

A CPDAG within a step and a directed graph per lag

LPCMCI

The same, with unmeasured series

A time-series PAG

Granger

Lagged links

One directed graph per lag, with the coefficients as weights

VARMA-LiNGAM

Weighted links within a step and from the step before

Weighted graphs for each lag and the moving average

LongitudinalLiNGAM

Weighted links in a panel

Weighted graphs at every time point but the first

Choose a method for your data:

  • PCMCI+ when links can act within one time step, as they do when you measure once a day.

  • PCMCI or Granger when every effect takes at least one step.

  • LPCMCI when a variable you did not measure may drive several that you did.

  • VARMA-LiNGAM when you need the size of each effect and the noise is non-Gaussian.

  • LongitudinalLiNGAM when you follow many units over a few time points.

An example#

We simulate three daily series. Rain slows traffic the same day, traffic drives the next day’s sales, and each series carries over from the day before.

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

import andrey

# Three daily series. Rain slows traffic the same day; traffic drives the next day's sales; each
# series also carries over from the day before.
rng = np.random.default_rng(0)
days = 1000
e = rng.uniform(-1, 1, size=(days, 3))
x = np.zeros((days, 3))
for t in range(1, days):
    rain = 0.6 * x[t - 1, 0] + e[t, 0]
    traffic = 0.5 * x[t - 1, 1] - 0.6 * rain + e[t, 1]
    sales = 0.4 * x[t - 1, 2] + 0.7 * x[t - 1, 1] + e[t, 2]
    x[t] = rain, traffic, sales
data = pd.DataFrame(x, columns=["rain", "traffic", "sales"])

# Links from the past only, then links within a day as well.
print(andrey.pcmci(data, tau_max=1), end="\n\n")
print(andrey.pcmciplus(data, tau_max=1))
The links each method finds in the example
PCMCILinks from the past only; 2 links not in the model
yesterdaytodayrainraintraffictrafficsalessales
PCMCI+Links within a day as well; the model's links exactly
yesterdaytodayrainraintraffictrafficsalessales
  • in the model, from yesterday
  • in the model, within the day
  • not in the model

PCMCI+ finds exactly the five links in the model, including the same-day link from rain to traffic.

PCMCI tests only lagged links, so it cannot find the same-day effect. It also finds two links absent from the model. Yesterday’s rain predicts today’s traffic because rain persists and today’s rain slows today’s traffic. Yesterday’s sales also predict today’s traffic. When your measurements are coarser than the effects, use a method that tests links within a step.

The script prints both results:

example.py#
pcmci  temporal  |  3 nodes  |  1 lags

  lag 1  |  6 edges (6 directed)
    rain -> rain
    rain -> traffic
    traffic -> traffic
    traffic -> sales
    sales -> traffic
    sales -> sales

  p_values: a list of 1, too long to print; read metadata['p_values']
  statistics: a list of 1, too long to print; read metadata['statistics']
  parents: [[[0, 1]], [[1, 1], [0, 1]], [[1, 1], [2, 1], [0, 1]]]

pcmciplus  temporal  |  3 nodes  |  2 lags

  lag 0  |  1 edges (1 directed)
    rain -> traffic

  lag 1  |  4 edges (4 directed)
    rain -> rain
    traffic -> traffic
    traffic -> sales
    sales -> sales

  p_values: a list of 2, too long to print; read metadata['p_values']
  statistics: a list of 2, too long to print; read metadata['statistics']
  parents: [[[0, 1]], [[1, 1], [0, 1]], [[1, 1], [2, 1]]]
  conflicts: []
  ambiguous_triples: []

Benchmark results#

We compared each method with a package that implements it, using the simulations in the method’s paper: PCMCI, PCMCI+, and LPCMCI with tigramite, and the two LiNGAM methods with lingam.

Method

Compared with

Andrey’s speedup

Graph

PCMCI+

tigramite

more than 50x at every size; more than 100x at 80 variables and 2,000 steps

the same as tigramite’s on every dataset

PCMCI

tigramite

more than 20x at 10 variables

the same as tigramite’s on every dataset

LPCMCI

tigramite

more than 8x at every size

the same on 73 of 90 datasets; of the other 17, fewer errors on 12 and more on 5

LongitudinalLiNGAM

lingam

more than 1.3x at every timed size

the same on 231 of 240 datasets; of the other 9, fewer errors on 8 and more on 1

Each speedup holds at every size it names, where Andrey’s graphs have fewer errors than an empty graph.

The 0.5.0 release card reports every measurement for all six methods, including Granger and VARMA-LiNGAM. It also gives the sizes, machine, settings, and known limits.

Speedup over each method's other package
Method
d=10, n=50023.2x
d=10, n=1,00024.0x
d=10, n=2,00027.4x
d=20, n=50051.4x
d=20, n=1,00058.8x
d=20, n=2,00069.5x
d=40, n=50086.6x
d=40, n=1,000104x
d=40, n=2,000125x
d=80, n=50088.6x
d=80, n=1,000132x
d=80, n=2,000166x
d=160, n=1,00091.6x
d=10, n=50052.0x
d=10, n=1,00054.8x
d=10, n=2,00067.8x
d=20, n=50072.9x
d=20, n=1,00082.9x
d=20, n=2,00096.6x
d=40, n=50064.7x
d=40, n=1,00069.4x
d=40, n=2,00084.3x
d=80, n=1,00096.4x
d=80, n=2,000116x
d=5 (4 observed), n=5008.56x
d=5 (4 observed), n=1,00012.0x
d=5 (4 observed), n=2,00015.9x
d=10 (7 observed), n=50010.4x
d=10 (7 observed), n=1,00012.6x
d=10 (7 observed), n=2,00018.7x
d=20 (14 observed), n=50010.8x
d=20 (14 observed), n=1,00012.2x
d=20 (14 observed), n=2,00016.5x
d=10, n=1,0003.34x
d=20, n=1,0003.09x
d=40, n=1,0002.83x
d=80, n=1,0002.15x
d=160, n=1,0001.59x
d=3, n=1001.05x
d=3, n=3001.00x
d=3, n=1,0001.02x
d=5, n=1001.06x
d=5, n=3000.962x
d=5, n=1,0001.03x
d=10, n=1001.03x
d=10, n=3001.01x
d=10, n=1,0001.04x
d=20, n=1001.11x
d=20, n=3001.13x
d=20, n=1,0001.12x
d=2, n=20, M=1,0001.37x
d=2, n=50, M=1001.60x
d=2, n=50, M=2001.42x
d=2, n=50, M=5001.34x
d=2, n=50, M=1,0001.33x
d=3, n=20, M=1001.55x
d=3, n=20, M=2001.47x
d=3, n=20, M=5001.52x
d=3, n=20, M=1,0001.66x
d=3, n=50, M=1001.52x
d=3, n=50, M=2001.54x
d=3, n=50, M=5001.59x
d=3, n=50, M=1,0001.55x
d=5, n=20, M=1001.90x
d=5, n=20, M=2002.02x
d=5, n=20, M=5002.24x
d=5, n=20, M=1,0001.77x
d=5, n=50, M=1001.87x
d=5, n=50, M=2001.95x
d=5, n=50, M=5001.96x
d=5, n=50, M=1,0001.84x

Each bar is the median, over the datasets of that size, of the other package's time divided by Andrey's on the same dataset. Each method's bars share its own scale.

Speed is not accuracy. On the larger simulated graphs, PCMCI’s graphs have more errors than an empty graph in both packages, and so do Granger’s. A fast answer still needs enough data for the question; the release card lists where these methods fall short.

Other highlights#

  • A Student-t test for PC and FCI. pc, fci, gfci, and cdnod take indep_test="student_t", the partial-correlation test that the time-series methods use.

  • VARMA-LiNGAM weights. With prune=True, VARMA-LiNGAM refits the weights it keeps by least squares. These weights are no longer shrunk toward zero. max_iter sets the iteration limit of its likelihood fit.

  • Granger in any units. Granger standardizes each series, so its answer does not depend on the units of any variable.

For details, read the changelog.

Try it#

pip install -U andrey-core

The time-series notebook runs all six methods on a simulated series. You can open it in Colab, then pass your own table, one row per time step, to any of them.

Help us build it#

Andrey is in alpha. 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 you can set up.