moonbit_dowhy

    MoonBit port of DoWhy 0.14 (PyWhy). Causal graph construction and analysis, effect identification (backdoor / frontdoor / complete adjustment), estimation (linear regression, generalized linear model, propensity score weighting / matching / stratification, doubly robust, instrumental variable, two stage, regression discontinuity), refutation (bootstrap, placebo treatment, data subset, dummy outcome, unobserved common cause), intervention sampling (do samplers), temporal graphs, causal discovery (the PC algorithm with the Fisher-z test) and the portable core of the graphical causal models (the p-value merging, the bootstrap confidence intervals, the Kullback-Leibler divergences, the density estimators, the Shapley values, the independence tests, the prediction models, the causal mechanisms, the fitting of a causal model and the what-if queries (the interventional samples, the counterfactual samples and the average causal effect). Pure MoonBit with zero external runtime dependencies.

    causal-inference
    dowhy
    causal-graph
    causal-discovery
    backdoor
    frontdoor
    propensity-score
    treatment-effect
    refutation
    identifiers
    estimators
    shapley-values
    independence-test
    structural-causal-model
    causal-mechanism
    what-if
    counterfactual
    Download zip
    Author
    Version
    0.0.6
    License
    MIT
    Last updated
    2 hours ago
    Downloads
    19

    No dependencies

    #moonbit_dowhy

    A MoonBit port of DoWhy 0.14 (PyWhy) -- causal graph construction, effect identification, effect estimation and refutation, with zero external runtime dependencies.

    CI MoonBit

    The package ships as riantr/moonbit_dowhy and runs on native, wasm, wasm-gc and js. The test suite executes on all four.

    #Status

    AreaUpstreamThis port
    Causal graph, d-separationdowhy.graph, dowhy.causal_graphported
    Identificationdowhy.causal_identifierported (backdoor, frontdoor, general adjustment, IV, mediation)
    Estimationdowhy.causal_estimatorsported (linear regression, generalised linear model, propensity score weighting / matching / stratification, doubly robust, instrumental variable, two stage, regression discontinuity)
    Refutationdowhy.causal_refutersported (bootstrap, placebo treatment, data subset, dummy outcome, random common cause, unobserved common cause)
    Synthetic datadowhy.datasetsported (linear_dataset and the fixtures the upstream tests use)
    Categorical encodingdowhy.utils.encodingported (the one-hot encoder and the encoder registry of the estimators)
    Dimensionality reductiondowhy.data_transformersported (the PCA reducer)
    Intervention samplingdowhy.do_sampler, dowhy.do_samplersported (the weighting, multivariate weighting and kernel density samplers) -- the MCMC sampler is not ported, it needs an inference engine upstream takes from PyMC3
    Time seriesdowhy.timeseriesported (the temporal graph and the temporal shift)
    Causal discoverydowhy.graph_learnersported (the PC algorithm with the Fisher-z test of conditional independence)
    Causal discoverydowhy.graph_learners (GES, CDT, LiNGAM)not ported -- upstream delegates those to causal-learn / cdt / lingam, which this dependency-free port does not wrap
    Graphical causal modelsdowhy.gcmpartially ported -- the configuration, the general helpers, the p-value merging, the F-test, the marginal expectation, the bootstrap confidence intervals, the Kullback-Leibler divergences, the kernel density estimator, the Shapley values, the independence tests, the prediction models, the causal mechanisms, the probabilistic causal models, the fitting of a causal model, the automatic assignment of the mechanisms and the what-if queries are ported; the model evaluation, the uncertainty estimates, the anomaly scorers and the distribution change tests are not ported
    Causal predictiondowhy.causal_predictionnot ported -- upstream depends on PyTorch

    Everything that is listed as ported is covered by tests that compare against the numbers the upstream implementation produces. The rows marked not ported are stated plainly rather than approximated: a wrong answer is worse than a missing one.

    #Install

    moon add riantr/moonbit_dowhy

    #Quick start

    import "riantr/moonbit_dowhy"
    import "riantr/moonbit_dowhy/datasets"

    /// Build a confounded data set: `W0 -> v0`, `W0 -> y`, `v0 -> y`.
    let dataset = @datasets.linear_dataset(
    42,
    1000,
    @datasets.LinearConfig::confounded(),
    )

    /// Build the model. The graph is derived from the semantic roles of the
    /// variables when no explicit graph is given.
    let model = @moonbit_dowhy.CausalModel::new(
    dataset.frame(),
    dataset.treatments(),
    [dataset.outcome()],
    None,
    dataset.common_causes(),
    dataset.instruments(),
    dataset.effect_modifiers(),
    dataset.frontdoor_variables(),
    )

    /// Identify the effect with the backdoor criterion.
    let estimand = model.identify_effect("backdoor")

    /// Estimate the average treatment effect.
    let estimate = model.estimate_effect(
    "backdoor.linear_regression",
    1.0,
    0.0,
    "ate",
    [],
    )

    println("ATE: " + estimate.estimate().to_string())
    println("95% CI: [" + estimate.confidence_interval_lower().to_string() +
    ", " + estimate.confidence_interval_upper().to_string() + "]")

    A runnable version of this snippet lives in examples/quick_start.mbt.

    #Package layout

    PackageImport pathPorts
    apiriantr/moonbit_dowhy/apithe subset of pandas.DataFrame the estimators use, plus the one-hot encoder
    utilsriantr/moonbit_dowhy/utilsmatrices, least squares, generalised linear models, distributions, descriptive statistics, a deterministic generator
    graphriantr/moonbit_dowhy/graphdowhy.graph, dowhy.causal_graph
    identifiersriantr/moonbit_dowhy/identifiersdowhy.causal_identifier
    modelriantr/moonbit_dowhy/modeldowhy.causal_model, dowhy.causal_estimator (the estimate objects)
    estimatorsriantr/moonbit_dowhy/estimatorsdowhy.causal_estimators
    refutersriantr/moonbit_dowhy/refutersdowhy.causal_refuters
    datasetsriantr/moonbit_dowhy/datasetsdowhy.datasets and the deterministic test fixtures
    data_transformersriantr/moonbit_dowhy/data_transformersdowhy.data_transformers (the PCA reducer)
    do_samplersriantr/moonbit_dowhy/do_samplersdowhy.do_sampler, dowhy.do_samplers
    timeseriesriantr/moonbit_dowhy/timeseriesdowhy.timeseries (the temporal graph and the temporal shift)
    cmriantr/moonbit_dowhy/cmthe PC causal discovery algorithm and the Fisher-z independence test
    gcmriantr/moonbit_dowhy/gcmthe portable core of dowhy.gcm: the statistics, the confidence intervals, the divergences, the density estimators, the Shapley values, the independence tests, the prediction models, the causal mechanisms, the fitting of a causal model and the what-if queries
    testsriantr/moonbit_dowhy/teststhe cross-package integration tests
    examplesriantr/moonbit_dowhy/examplesthe runnable quick start

    The root package riantr/moonbit_dowhy holds CausalModel, the entry point that ties the data, the graph and the estimators together.

    #Numerical conventions

    The port reproduces the numbers of DoWhy, so the conventions of NumPy, pandas, statsmodels and scikit-learn are deliberate rather than accidental:

    • variance uses ddof = 0 unless another degree of freedom is asked for; cov uses ddof = 1, like numpy.cov.
    • percentile uses the linear interpolation of NumPy.
    • average_rank reproduces pandas.Series.rank(ascending=True), so ties get the mean rank -- the propensity score stratification estimator depends on it.
    • round_half_even reproduces numpy.round and pandas.Series.round(0).
    • A rank deficient design is solved with the Moore-Penrose pseudo inverse, the minimum norm solution, like statsmodels.OLS, which uses pinv.
    • The residual variance uses df_resid = n - rank.
    • The logistic regression minimises sum_i log_loss(y_i, p_i) + 1/(2C) ||w||^2, the convention of sklearn.linear_model.LogisticRegression with its default penalty="l2" and C=1.0.
    • The generalised linear model is fitted with iteratively reweighted least squares and without a penalty, the convention of statsmodels.api.GLM; the covariance of its coefficients is the pseudo inverse of the weighted Gram matrix, and the dispersion of the binomial and the poisson family is fixed at one.
    • The one-hot encoder drops the first category of every column, like pandas.get_dummies and like the Encoders helper of upstream, and it learns its categories in lexicographic order, like numpy.unique.
    • The prediction model of the gcm package fits the way sklearn.linear_model.LinearRegression does: it centres the features and the target before the least squares fit and computes the intercept from the offsets. The centring matters, because the one-hot encoding of a categorical parent and the intercept are collinear and the two conventions spread the same fit over the columns differently. The least squares fit of the utils package has no intercept at all, so the two are not interchangeable.

    #Deliberate differences from upstream

    Five behaviours of the core differ on purpose, and each one is a case where the upstream code is order-dependent rather than wrong in an absolute sense:

    • The backdoor identifier enumerates its candidate adjustment variables in sorted order. Upstream iterates over a Python set, so its order depends on the hash seed; the sets it returns are the same.
    • The frontdoor identifier returns the smallest valid set. Upstream breaks out of its loop after the first match it finds and therefore returns the largest one.
    • The efficient backdoor criterion (the Smucler-Sapienza-Rotnitzky min-cut) is not implemented. It returns an empty adjustment set together with a message that says so, instead of returning a wrong one.
    • The propensity score estimators compute the scores of the data they are asked about with the model and the encoder that were fitted on the training data, so the scores follow the rows of that data. Upstream assigns the scores of the training data to the new rows by position, which mixes two different rows up. The scores of the training data itself are the same either way.
    • The generalised linear model estimator reports the analytic confidence interval and standard error of the effect, computed with the delta method from the covariance of the coefficients. Upstream leaves the interval to the bootstrap, because a link function other than the identity makes the effect a function of the coefficients rather than one of them.

    The do_samplers, timeseries and gcm packages differ from upstream in the following ways:

    • do_samplers replaces the df.causal.do(...) accessor of the pandas extension with a plain function, because the port has no accessor mechanism. The continuous treatment and the kernel density sampler use a Gaussian conditional density around the least squares fit where upstream uses KDEMultivariateConditional, and the categorical treatment uses the one versus rest logistic regression of upstream. The MCMC sampler is not ported: upstream samples a Bayesian network with PyMC3 and NUTS, and a hand written sampler would quietly change the numbers, so the method reports that it is unavailable instead of returning a wrong answer.
    • timeseries keeps the link types of the upstream array representation as their label strings ("-->", "<--", "o-o", "x-x"), because the toolchain cannot construct the variants of an enum of another package. The CSV and DOT readers are not ported, because the library has no file IO; create_graph_from_edges is the data form they feed.
    • gcm carries the two settings of dowhy.gcm.config in a Config value that the functions take as an argument, because MoonBit has no mutable global for them to live in, and it runs nothing in parallel.
    • gcm detects a categorical input from the numeric shape instead of from the numpy dtype, so a distinct number is a category of the categorical divergence and auto_estimate_kl_divergence does not dispatch to it. The GaussianMixtureDensityEstimator is not ported, because upstream uses the variational inference of sklearn.mixture.BayesianGaussianMixture; the kernel density estimator is ported exactly.
    • gcm draws the folds of the regression based independence test with its own generator instead of numpy's shuffled KFold, so its p-value differs from the upstream's on the same rows, and it regresses on the conditioning variable with the ordinary least squares fit where upstream selects a model automatically. Both make the same decision on the same data.
    • gcm draws a seed for every bootstrap resample and hands it to the estimation function, instead of seeding the global generator of numpy before the call, and its F-test adds the intercept column itself, because the least squares fit of the utils package has none.
    • gcm has one prediction model per kind: the linear regression and the logistic regression. Upstream wraps the whole zoo of the scikit-learn estimators and picks one of them by cross validation, so the mechanism that is fitted can be a random forest or a support vector machine; the port fits the model the kind of the target asks for.
    • gcm draws the randomness of a causal model from an explicit generator instead of the global generator of numpy, so the samples of a model are reproducible.
    • gcm has one causal model type. The upstream distinguishes the probabilistic causal model from the structural one, but every mechanism of the port is a functional causal model, so the two are the same here.
    • gcm encodes the discrete parents of a node with the categories the encoder was fitted on and encodes a category it has not seen with zeros, like the OneHotEncoder(handle_unknown="ignore") of upstream; the encoder of dowhy.utils.encoding drops the first category instead, because that is what the upstream helper of the estimators does.
    • gcm does not select the distribution of a root node automatically. The upstream compares every continuous family of scipy against the data and keeps the one with the smallest divergence; the port uses the distribution it was given.
    • gcm has three parametric noise distributions -- the normal, the Laplace and the uniform -- and the empirical one. Upstream has a parametric distribution of every family of scipy and a Bayesian Gaussian mixture, which the port does not have, because the mixture needs the variational inference of sklearn.mixture.BayesianGaussianMixture.
    • gcm keeps an intervention in a map of functions of a single value, not of a numpy array. A node of a causal model holds either a number or a category, so the value of an intervention is a CausalValue that is one of the two. The upstream intervention is a function of an array, so it can change the shape of a column; the port's cannot.
    • gcm has one causal query signature: the query of fit_and_compute takes the causal model alone and returns the numbers of the attribution. The upstream query takes any number of further arguments and returns a dictionary, and the upstream bootstrap_sampling alias is a functools.partial; the port has neither, because the confidence_intervals of the port hands the seed of the resample to the estimation function already.

    #Development

    The gate that a release has to pass:

    moon check --deny-warn # a warning is a failure moon test --deny-warn moon fmt --check typos

    The cross-package tests live in src/tests/ rather than inside the implementation packages, so that a published package never carries a test-only dependency on datasets.

    The Python helpers under _verify/ are the tooling that produced the upstream reference numbers the tests are compared against.

    #License

    MIT. See LICENSE.

    CausalModel

    A causal model.

    CausalModel::causal_graph

    The causal graph of the model.

    CausalModel::data

    The data of the model.

    CausalModel::estimate_effect

    fn CausalModel::estimate_effect(self : CausalModel, method_name : String, treatment_value : Double, control_value : Double, target_units : String, effect_modifier_names : Array[String]) ->
    CausalEstimate

    Estimate the causal effect.

    method_name names the estimator, e.g. "backdoor.linear_regression". The first segment of the name selects the identifier, exactly like dowhy does.

    When the effect has not been identified yet, it is identified with the identifier of the method name, so that a model that is used for a single estimate does not need an explicit identify_effect call.

    CausalModel::identified_estimand

    The estimand the identification produced.

    CausalModel::identify_effect

    Identify the causal effect.

    method_name selects the identifier: "backdoor" (the default), "frontdoor", "mediation", "iv" or "general_adjustment".

    CausalModel::new

    fn CausalModel::new(data :
    DataFrame
    , treatment : Array[String], outcome : Array[String], graph :
    DirectedGraph
    ?, common_causes : Array[String], instruments : Array[String], effect_modifiers : Array[String], mediators : Array[String]) -> CausalModel

    Build a causal model.

    When graph is None the graph is built from the semantic roles of the variables.

    CausalModel::refute_estimate

    fn CausalModel::refute_estimate(self : CausalModel, method_name : String, estimate :
    CausalEstimate
    , refuter_name : String, num_simulations : Int, random :
    Random
    ) ->
    CausalRefutation

    Refute the estimate.

    refuter_name is one of "bootstrap", "placebo", "subset", "dummy_outcome", "random_common_cause" or "add_unobserved_common_cause".

    Source Files

    Powered by MoonBit

    Site sourceReport issueModulesSkillsBuild queueStatistics

    © 2026 mooncakes.io