/// The causal model: the entry point of the library.
///
/// This is the port of `dowhy.causal_model.CausalModel`. It ties the data,
/// the causal graph and the estimators together:
///
/// ```moonbit
/// let model = CausalModel::new(data, ["treatment"], ["outcome"], None, ["confounder"], [], [], [])
/// let estimand = model.identify_effect("backdoor")
/// let estimate = model.estimate_effect("backdoor.linear_regression", 1.0, 0.0, "ate")
/// ```
///
/// The upstream accepts a graph as a networkx graph, as a GML string, as a DOT
/// string, as the graph of the daggity web site and as the graph of a
/// probabilistic causal model. The port accepts a graph as a directed graph
/// and as a GML string, a DOT string or a daggity string.
///|
/// A causal model.
pub struct CausalModel {
/// The data the analysis runs on.
data : @api.DataFrame
/// The treatment variables.
treatment : Array[String]
/// The outcome variables.
outcome : Array[String]
/// The causal graph, with the semantic role of every node.
graph : @graph.CausalGraph
/// The observed nodes of the graph, which are the columns of the data.
observed_nodes : Array[String]
/// The estimand the identification produced.
mut estimand : @identifiers.IdentifiedEstimand
/// The estimators the model has built, keyed by the name of the method.
///
/// The estimators are stored by reference, so the estimator the cache hands
/// out is the estimator that the estimate was computed with.
estimator_cache : Map[String, @estimators.CausalEstimator]
/// Whether the identification may proceed when the effect is not
/// identifiable.
proceed_when_unidentifiable : Bool
/// Whether the common causes and the instruments are read from the graph.
identify_vars : Bool
/// The kind of the estimand the model asks for.
estimand_type : String
}
///|
/// Build a causal model.
///
/// When `graph` is `None` the graph is built from the semantic roles of the
/// variables.
///
/// The `missing_nodes_as_confounders` flag adds the columns of the data that
/// are not part of the graph as common causes of the treatment and of the
/// outcome, the way the upstream does it.
pub fn CausalModel::new(
data : @api.DataFrame,
treatment : Array[String],
outcome : Array[String],
graph : @graph.DirectedGraph?,
common_causes : Array[String],
instruments : Array[String],
effect_modifiers : Array[String],
mediators : Array[String],
missing_nodes_as_confounders~ : Bool = false,
identify_vars~ : Bool = false,
proceed_when_unidentifiable~ : Bool = false,
estimand_type~ : String = "nonparametric-ate",
) -> CausalModel {
let observed = data.column_names()
let causal = @graph.CausalGraph::new(
treatment, outcome, graph, common_causes, instruments, effect_modifiers, mediators,
observed,
)
build_model(
data, treatment, outcome, causal, observed, missing_nodes_as_confounders, identify_vars,
proceed_when_unidentifiable, estimand_type,
)
}
///|
/// Build a causal model whose graph is given as a string.
///
/// The string is a GML graph, a DOT graph or the graph of the daggity web
/// site. The upstream reads a graph file as well; the port has no access to
/// the file system, so the caller reads the file and hands its content to the
/// model.
///
/// The model aborts when the string is not a graph the port can read; the
/// function `is_supported_graph_format` tells whether a string is one.
pub fn CausalModel::new_with_graph_string(
data : @api.DataFrame,
treatment : Array[String],
outcome : Array[String],
graph : String,
common_causes : Array[String],
instruments : Array[String],
effect_modifiers : Array[String],
missing_nodes_as_confounders~ : Bool = false,
identify_vars~ : Bool = false,
proceed_when_unidentifiable~ : Bool = false,
estimand_type~ : String = "nonparametric-ate",
) -> CausalModel {
let parsed = match parse_graph_string(graph) {
Some(directed) => directed
None =>
abort(
"Incorrect format: Please provide graph as a GML string, a DOT string or the graph of the daggity web site.",
)
}
let observed = data.column_names()
let causal = @graph.CausalGraph::new(
treatment,
outcome,
Some(parsed),
common_causes,
instruments,
effect_modifiers,
[],
observed,
)
build_model(
data, treatment, outcome, causal, observed, missing_nodes_as_confounders, identify_vars,
proceed_when_unidentifiable, estimand_type,
)
}
///|
/// The common model of the two constructors.
fn build_model(
data : @api.DataFrame,
treatment : Array[String],
outcome : Array[String],
causal : @graph.CausalGraph,
observed : Array[String],
missing_nodes_as_confounders : Bool,
identify_vars : Bool,
proceed_when_unidentifiable : Bool,
estimand_type : String,
) -> CausalModel {
if missing_nodes_as_confounders {
let _ = causal.add_missing_nodes_as_common_causes(observed)
}
let estimand = @identifiers.IdentifiedEstimand::new(treatment, outcome)
estimand.set_estimand_type(estimand_type)
{
data,
treatment,
outcome,
graph: causal,
observed_nodes: observed,
estimand,
estimator_cache: Map([]),
proceed_when_unidentifiable,
identify_vars,
estimand_type,
}
}
///|
/// Whether the identification may proceed when the effect is not identifiable.
///
/// This is the port of the `proceed_when_unidentifiable` flag of the upstream.
pub fn CausalModel::proceeds_when_unidentifiable(self : CausalModel) -> Bool {
self.proceed_when_unidentifiable
}
///|
/// Whether the common causes and the instruments are read from the graph.
///
/// This is the port of the `identify_vars` flag of the upstream.
pub fn CausalModel::identifies_variables(self : CausalModel) -> Bool {
self.identify_vars
}
///|
/// The kind of the estimand the model asks for.
pub fn CausalModel::estimand_type(self : CausalModel) -> String {
self.estimand_type
}
///|
/// Whether the given string is a graph the port can read.
///
/// The strings are the GML graphs, the DOT graphs and the graphs of the
/// daggity web site, which are the formats the upstream reads as well.
pub fn is_supported_graph_format(graph : String) -> Bool {
match parse_graph_string(graph) {
Some(_) => true
None => false
}
}
///|
/// The graph of a string, when the string holds a graph the port can read.
///
/// The graph of the daggity web site is turned into a DOT graph first, the
/// way the upstream does it.
fn parse_graph_string(graph : String) -> @graph.DirectedGraph? {
let text = graph.trim().to_owned()
// The upstream looks for the `dag` prefix with the regular expression
// `^dag`.
if text.has_prefix("dag") {
return Some(@graph.parse_dot(@graph.daggity_to_dot(text)))
}
// The upstream replaces the newlines of the string before it looks at it.
let flat = text.replace_all(old="\n", new=" ")
// The upstream looks for the DOT graph with the regular expression
// `.*graph\s*\{.*\}\s*` and for the GML graph with the regular
// expression `.*graph\s*\[.*\]\s*`, so the whitespace between the
// keyword and the bracket is optional.
if holds_graph_bracket(flat, '{') && flat.contains("}") {
return Some(@graph.parse_dot(flat))
}
if holds_graph_bracket(flat, '[') && flat.contains("]") {
return Some(@graph.parse_gml(flat))
}
None
}
///|
/// Whether the string holds a `graph` keyword that is followed by the given
/// bracket, with any amount of whitespace in between.
///
/// This is the port of the two regular expressions `graph\s*\{` and
/// `graph\s*\[` of the upstream.
fn holds_graph_bracket(text : String, bracket : Char) -> Bool {
let chars = text.to_array()
let mut i = 0
while i + 5 <= chars.length() {
if chars[i] == 'g' &&
chars[i + 1] == 'r' &&
chars[i + 2] == 'a' &&
chars[i + 3] == 'p' &&
chars[i + 4] == 'h' {
let mut j = i + 5
while j < chars.length() && chars[j].is_whitespace() {
j = j + 1
}
if j < chars.length() && chars[j] == bracket {
return true
}
}
i = i + 1
}
false
}
///|
/// The data of the model.
pub fn CausalModel::data(self : CausalModel) -> @api.DataFrame {
self.data
}
///|
/// The causal graph of the model.
pub fn CausalModel::causal_graph(self : CausalModel) -> @graph.DirectedGraph {
self.graph.directed_graph()
}
///|
/// The causal graph of the model, with the semantic role of every node.
pub fn CausalModel::graph_with_roles(self : CausalModel) -> @graph.CausalGraph {
self.graph
}
///|
/// The estimand the identification produced.
pub fn CausalModel::identified_estimand(
self : CausalModel,
) -> @identifiers.IdentifiedEstimand {
self.estimand
}
///|
/// The common causes of the treatment and of the outcome.
///
/// This is the port of `dowhy.causal_model.CausalModel.get_common_causes`,
/// which asks the graph for the common causes of the two variables.
pub fn CausalModel::get_common_causes(self : CausalModel) -> Array[String] {
self.graph.get_common_causes(self.treatment, self.outcome)
}
///|
/// The instruments of the treatment.
///
/// This is the port of `dowhy.causal_model.CausalModel.get_instruments`.
pub fn CausalModel::get_instruments(self : CausalModel) -> Array[String] {
self.graph.get_instruments(self.treatment, self.outcome)
}
///|
/// The effect modifiers of the treatment.
///
/// This is the port of `dowhy.causal_model.CausalModel.get_effect_modifiers`.
pub fn CausalModel::get_effect_modifiers(self : CausalModel) -> Array[String] {
self.graph.get_effect_modifiers(self.treatment, self.outcome)
}
///|
/// The summary of the model.
///
/// This is the port of `dowhy.causal_model.CausalModel.summary`. The upstream
/// prints the summary and logs it; the port returns it.
pub fn CausalModel::summary(self : CausalModel) -> String {
"Model to find the causal effect of treatment " +
render_names(self.treatment) +
" on outcome " +
render_names(self.outcome)
}
///|
/// The names of the variables, the way the upstream renders a list of names.
fn render_names(names : Array[String]) -> String {
let mut out = "["
for i = 0; i < names.length(); i = i + 1 {
if i > 0 {
out = out + ", "
}
out = out + "'" + names[i] + "'"
}
out + "]"
}
///|
/// The variables of the graph that are not columns of the data.
///
/// The upstream warns about them when the model is built. The port has no
/// warning system, so it reports them instead.
pub fn CausalModel::unobserved_graph_variables(
self : CausalModel,
) -> Array[String] {
let out : Array[String] = []
for node in self.graph.get_all_nodes(true) {
if !self.observed_nodes.contains(node) {
out.push(node)
}
}
out.sort_by(fn(a, b) { compare_names(a, b) })
out
}
///|
/// The columns of the data that are not variables of the graph.
///
/// The upstream logs them when the model is built. The port reports them
/// instead.
pub fn CausalModel::unused_data_variables(self : CausalModel) -> Array[String] {
let out : Array[String] = []
for node in self.observed_nodes {
if !self.graph.get_all_nodes(true).contains(node) {
out.push(node)
}
}
out.sort_by(fn(a, b) { compare_names(a, b) })
out
}
///|
/// The comparison of two names, the way the upstream sorts a list of names.
fn compare_names(a : String, b : String) -> Int32 {
if a < b {
-1
} else if a > b {
1
} else {
0
}
}
///|
/// The estimator the model has built for the given method, when it has one.
///
/// This is the port of `dowhy.causal_model.CausalModel.get_estimator`. The
/// upstream keys the cache by the whole name of the method.
pub fn CausalModel::get_estimator(
self : CausalModel,
method_name : String,
) -> @estimators.CausalEstimator? {
self.estimator_cache.get(method_name)
}
///|
/// Identify the causal effect.
///
/// `method_name` selects the identifier: `"backdoor"` (the default),
/// `"frontdoor"`, `"mediation"`, `"iv"` or `"general_adjustment"`.
///
/// The kind of the estimand is the one of the model, the way the upstream uses
/// the kind it was built with. The mediation of the effect is the natural
/// indirect effect, which the upstream identifies with its own function.
pub fn CausalModel::identify_effect(
self : CausalModel,
method_name : String,
) -> @identifiers.IdentifiedEstimand {
let estimand = @identifiers.identify_effect_auto(
self.graph.directed_graph(),
self.treatment,
self.outcome,
self.observed_nodes,
estimand_type_of(self.estimand_type, method_name),
backdoor_strategy_of(method_name),
)
self.estimand = estimand
estimand
}
///|
/// The kind of the estimand the method name asks for.
///
/// The mediation of the effect is the natural indirect effect, the one the
/// upstream identifies with `identify_nie_effect`.
fn estimand_type_of(estimand_type : String, method_name : String) -> String {
if first_segment(method_name) == "mediation" {
"nonparametric-nie"
} else {
estimand_type
}
}
///|
/// The backdoor strategy that the given identifier method uses.
///
/// The method names of the model are the identifiers of the upstream, and the
/// upstream passes the name of the backdoor strategy to the identifier. A name
/// that is not a strategy of the upstream uses the default backdoor strategy,
/// the way the upstream does when it is given no strategy at all.
fn backdoor_strategy_of(method_name : String) -> String {
if @identifiers.is_backdoor_strategy(method_name) {
method_name
} else {
@identifiers.BACKDOOR_DEFAULT
}
}
///|
/// Build the estimator the method name refers to.
fn CausalModel::build_estimator(
self : CausalModel,
method_name : String,
) -> @estimators.CausalEstimator {
let estimand = self.estimand
match method_name {
"backdoor.linear_regression" =>
@estimators.CausalEstimator::linear_regression(estimand)
"backdoor.propensity_score_weighting" =>
@estimators.CausalEstimator::propensity_score_weighting(
estimand,
0.05,
0.95,
@estimators.WeightingScheme::ips_weight(),
)
"backdoor.propensity_score_matching" =>
@estimators.CausalEstimator::propensity_score_matching(estimand)
"backdoor.propensity_score_stratification" =>
@estimators.CausalEstimator::propensity_score_stratification(
estimand,
@estimators.NumStrata::auto(),
10,
)
"backdoor.doubly_robust" =>
@estimators.CausalEstimator::doubly_robust(estimand, 0.01, 0.99)
"iv.instrumental_variable" =>
@estimators.CausalEstimator::instrumental_variable(estimand, "")
"frontdoor.two_stage_regression" =>
@estimators.CausalEstimator::two_stage_regression(estimand)
"mediation.two_stage_regression" =>
@estimators.CausalEstimator::two_stage_regression(estimand)
"backdoor.generalized_linear_model" =>
@estimators.CausalEstimator::generalized_linear_model(
estimand,
@utils.GlmFamily::gaussian(),
)
"backdoor.generalized_linear_model.gaussian" =>
@estimators.CausalEstimator::generalized_linear_model(
estimand,
@utils.GlmFamily::gaussian(),
)
"backdoor.generalized_linear_model.binomial" =>
@estimators.CausalEstimator::generalized_linear_model(
estimand,
@utils.GlmFamily::binomial(),
)
"backdoor.generalized_linear_model.poisson" =>
@estimators.CausalEstimator::generalized_linear_model(
estimand,
@utils.GlmFamily::poisson(),
)
_ =>
abort(
"The method " + method_name + " is not supported by this port of DoWhy",
)
}
}
///|
/// 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.
///
/// The effect modifiers of the estimate are the effect modifiers of the graph
/// when the caller names none, the way the upstream does it. The regression
/// based estimators then report a conditional estimate for every group of the
/// effect modifier.
///
/// The estimator the estimate is computed with is kept in the cache of the
/// model, keyed by the name of the method, the way the upstream keeps it. When
/// `fit_estimator` is false the cached estimator is used as it is, without a
/// new fit, which is what the upstream does when the estimator is asked for
/// on new data.
pub fn CausalModel::estimate_effect(
self : CausalModel,
method_name : String,
treatment_value : Double,
control_value : Double,
target_units : String,
effect_modifier_names~ : Array[String] = [],
fit_estimator~ : Bool = true,
) -> @model.CausalEstimate {
// The identifier of the estimator is the first segment of its name.
let identifier = first_segment(method_name)
if !self.estimand.has_estimand(identifier) {
let _ = self.identify_effect(identifier)
}
self.estimand.set_identifier_method(identifier)
let cached = self.estimator_cache.get(method_name)
let estimator = match cached {
Some(existing) => existing
None => {
let built = self.build_estimator(method_name)
self.estimator_cache.set(method_name, built)
built
}
}
// The upstream takes the effect modifiers of the graph when the caller does
// not name any.
let modifiers = if effect_modifier_names.length() == 0 {
self.graph.get_effect_modifiers(self.treatment, self.outcome)
} else {
effect_modifier_names
}
if fit_estimator {
estimator.fit(self.data, modifiers)
}
estimator.estimate_effect(
self.data,
treatment_value,
control_value,
target_units,
)
}
///|
/// Refute the estimate.
///
/// `refuter_name` is one of `"bootstrap"`, `"placebo"`, `"subset"`,
/// `"dummy_outcome"`, `"random_common_cause"` or
/// `"add_unobserved_common_cause"`.
///
/// `placebo_type` chooses the kind of the placebo the placebo treatment
/// refuter builds: a random variable of the kind of the treatment, or the rows
/// of the treatment, permuted. The upstream forwards it as a keyword argument
/// of its own method.
pub fn CausalModel::refute_estimate(
self : CausalModel,
method_name : String,
estimate : @model.CausalEstimate,
refuter_name : String,
num_simulations : Int32,
random : @utils.Random,
placebo_type~ : @refuters.PlaceboType = @refuters.PlaceboType::random_data(),
) -> @refuters.CausalRefutation {
let estimator = self.build_estimator(method_name)
estimator.fit(self.data, [])
let estimand = self.estimand
// The identifier of the estimator is the first segment of its name, the way
// the estimate sets it.
estimand.set_identifier_method(first_segment(method_name))
match refuter_name {
"bootstrap" =>
@refuters.refute_bootstrap(
estimator,
estimand,
self.data,
num_simulations,
1.0,
estimate.control_value,
estimate.treatment_value,
"ate",
random,
)
"placebo" =>
@refuters.refute_placebo_treatment(
estimator,
estimand,
self.data,
self.treatment[0],
num_simulations,
estimate.treatment_value,
estimate.control_value,
"ate",
random,
placebo_type~,
)
"subset" =>
@refuters.refute_data_subset(
estimator,
estimand,
self.data,
0.8,
num_simulations,
estimate.treatment_value,
estimate.control_value,
"ate",
random,
)
"dummy_outcome" =>
@refuters.refute_dummy_outcome(
estimator,
estimand,
self.data,
self.outcome[0],
num_simulations,
estimate.treatment_value,
estimate.control_value,
"ate",
random,
)
"random_common_cause" =>
@refuters.refute_random_common_cause(
estimator,
estimand,
self.data,
num_simulations,
estimate.treatment_value,
estimate.control_value,
"ate",
random,
)
"add_unobserved_common_cause" =>
@refuters.refute_add_unobserved_common_cause(
estimator,
estimand,
self.data,
self.treatment[0],
self.outcome[0],
0.05,
num_simulations,
estimate.treatment_value,
estimate.control_value,
"ate",
random,
)
_ => abort("The refuter " + refuter_name + " is not supported")
}
}
///|
/// Assess the support and the overlap of the covariates with boolean rules.
///
/// This is the port of the `assess_overlap` refuter of the upstream. The
/// upstream returns the analyzer of the rules from its `refute_estimate`, and
/// the port returns it from a method of its own, because the rules are not a
/// refutation of the estimate.
///
/// The rules are learned with respect to the backdoor variables of the estimand,
/// the way the upstream takes them.
pub fn CausalModel::refute_assess_overlap(
self : CausalModel,
support_config~ : @overrule.SupportConfig = @overrule.SupportConfig::new(),
overlap_config~ : @overrule.OverlapConfig = @overrule.OverlapConfig::new(),
overlap_eps~ : Double = 0.1,
support_only~ : Bool = false,
overlap_only~ : Bool = false,
) -> @overrule.OverlapAnalyzer {
let backdoor = self.estimand.get_backdoor_variables(
self.estimand.default_backdoor_id(),
)
let names = self.data.numeric_column_names()
let columns : Array[Array[Double]] = []
for name in names {
columns.push(self.data.numeric(name))
}
@overrule.refute_assess_overlap(
columns,
names,
backdoor,
self.treatment[0],
support_config,
overlap_config,
overlap_eps,
support_only,
overlap_only,
)
}
///|
/// The first segment of a dotted name.
fn first_segment(name : String) -> String {
let parts = name.split(".")
match parts.next() {
Some(first) => first.to_owned()
None => name
}
}