///|
/// The refuter that adds an unobserved common cause.
///
/// This is the port of the `direct-simulation` branch of
/// `dowhy.causal_refuters.add_unobserved_common_cause`: a simulated confounder
/// is drawn for every row, it is made to act on the treatment and on the
/// outcome with the strengths the caller gives, and the estimator is refitted on
/// the changed data. A refutation that works reports how far the estimate moves,
/// which is the sensitivity of the estimate to a confounder the data does not
/// hold.
///
/// The two effects are the ones of the upstream `_include_confounders_effect`:
///
/// * `linear` adds `kappa * w` to the treatment and `-kappa * w` to the outcome.
/// The sign of the outcome is negative on purpose: the confounder of the data
/// is assumed to have acted on both, so subtracting it from the outcome is
/// what removes its effect.
/// * `binary_flip` flips a value of the column with the probability the strength
/// describes. The strength is read as the probability of a flip, and the
/// values whose draw is beyond the edge of the central interval of the
/// standard normal that the probability spans are the ones that flip. A
/// strength of `0` flips nothing, a strength of `0.5` flips half of the rows
/// and a strength of `1` flips every row.
///
/// The upstream draws its confounder with the global generator of numpy and the
/// port draws it with the generator it is given, so the refuted effects of the
/// two are not the same numbers. What the upstream test asserts is unaffected:
/// with a strength of zero the data does not change at all, so the refuted
/// effect is the estimate of the data exactly.
///|
/// The kind of effect the simulated confounder has on the treatment.
pub enum ConfoundersEffectOnT {
/// A value of the column is flipped with the probability the strength
/// describes.
BinaryFlip
/// The strength times the confounder is added to the column.
Linear
}
///|
/// A flipped value of the treatment.
pub fn ConfoundersEffectOnT::binary_flip() -> ConfoundersEffectOnT {
BinaryFlip
}
///|
/// A linear effect on the treatment.
pub fn ConfoundersEffectOnT::linear() -> ConfoundersEffectOnT {
Linear
}
///|
/// The kind of effect the simulated confounder has on the outcome.
pub enum ConfoundersEffectOnY {
/// A value of the column is flipped with the probability the strength
/// describes.
BinaryFlip
/// Minus the strength times the confounder is added to the column.
Linear
}
///|
/// A flipped value of the outcome.
pub fn ConfoundersEffectOnY::binary_flip() -> ConfoundersEffectOnY {
BinaryFlip
}
///|
/// A linear effect on the outcome.
pub fn ConfoundersEffectOnY::linear() -> ConfoundersEffectOnY {
Linear
}
///|
/// The refuter that adds an unobserved common cause.
///
/// `kappa_t` is the strength of the confounder on the treatment and `kappa_y` the
/// strength on the outcome. The simulation is the one of the upstream: a single
/// confounder is drawn and a single refutation is reported, so there is no
/// interval to report besides the refuted effect itself.
pub fn refute_add_unobserved_common_cause(
estimator : @estimators.CausalEstimator,
estimand : @identifiers.IdentifiedEstimand,
data : @api.DataFrame,
treatment_name : String,
outcome_name : String,
kappa_t : Double,
kappa_y : Double,
treatment_value : Double,
control_value : Double,
target_units : String,
random : @utils.Random,
effect_on_t~ : ConfoundersEffectOnT = ConfoundersEffectOnT::binary_flip(),
effect_on_y~ : ConfoundersEffectOnY = ConfoundersEffectOnY::linear(),
) -> CausalRefutation {
let n = data.nrows()
// One confounder per row, shared by the treatment and the outcome, the way the
// upstream draws it.
let confounder : Array[Double] = []
for i = 0; i < n; i = i + 1 {
confounder.push(random.next_normal())
}
let treatment = match effect_on_t {
BinaryFlip =>
flip_with_confounder(data.numeric(treatment_name), confounder, kappa_t)
Linear => add_effect(data.numeric(treatment_name), confounder, kappa_t)
}
let outcome = match effect_on_y {
BinaryFlip =>
flip_with_confounder(data.numeric(outcome_name), confounder, kappa_y)
Linear => add_effect(data.numeric(outcome_name), confounder, -kappa_y)
}
let changed = data.with_numeric(treatment_name, treatment)
let changed = changed.with_numeric(outcome_name, outcome)
let refutation = CausalRefutation::new(
estimator
.estimate_effect(data, treatment_value, control_value, target_units)
.estimate(),
reestimate(
estimator, estimand, changed, treatment_value, control_value, target_units,
),
"Refute: Add an Unobserved Common Cause",
)
// A simulation of a single confounder has no spread, so the interval of the
// refutation is the refuted effect itself.
refutation.new_effect_lower = refutation.new_effect
refutation.new_effect_upper = refutation.new_effect
refutation
}
///|
/// The values with the confounder added, weighted by the strength.
fn add_effect(
values : Array[Double],
confounder : Array[Double],
strength : Double,
) -> Array[Double] {
let out : Array[Double] = []
for i = 0; i < values.length(); i = i + 1 {
out.push(values[i] + strength * confounder[i])
}
out
}
///|
/// The values with the ones that the draw picks flipped.
///
/// The strength is the probability of a flip. The upstream builds the central
/// interval of the standard normal that the probability spans and takes its
/// lower edge when the probability is at least one half and its upper edge
/// otherwise. A strength of `0` spans the whole line, so the upper edge is
/// infinite and nothing flips; a strength of `0.5` gives the edge zero, so half
/// of the rows flip; a strength of `1` spans the whole line as well, so the lower
/// edge is negative infinity and every row flips. A value flips to one minus
/// itself, the way the upstream flips a column of zeros and ones.
fn flip_with_confounder(
values : Array[Double],
confounder : Array[Double],
strength : Double,
) -> Array[Double] {
let alpha = if strength >= 0.5 {
2.0 * strength - 1.0
} else {
1.0 - 2.0 * strength
}
let lower = @utils.normal_ppf((1.0 - alpha) / 2.0)
let upper = @utils.normal_ppf((1.0 + alpha) / 2.0)
let edge = if strength >= 0.5 { lower } else { upper }
let out : Array[Double] = []
for i = 0; i < values.length(); i = i + 1 {
if confounder[i] >= edge {
out.push(1.0 - values[i])
} else {
out.push(values[i])
}
}
out
}