use std::collections::HashMap;
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
use pyo3::types::PyDict;
use crate::asv::{AsvExplainer, AsvResult};
use crate::error::CausasvError;
use crate::graph::{Dag as RustDag, NodeId};
use crate::sampler::{AdaptiveSamplingConfig, SamplingConfig};
#[pyclass(name = "CausalDAG")]
pub struct PyCausalDAG {
pub(crate) inner: RustDag,
}
#[pymethods]
impl PyCausalDAG {
#[new]
fn new() -> Self {
Self {
inner: RustDag::new(),
}
}
#[staticmethod]
fn from_networkx(g: &Bound<'_, PyAny>) -> PyResult<Self> {
let mut inner = RustDag::new();
for edge in g.call_method0("edges")?.try_iter()? {
let edge = edge?;
let from: String = edge.get_item(0)?.extract()?;
let to: String = edge.get_item(1)?.extract()?;
let from_id = inner.add_node(&from);
let to_id = inner.add_node(&to);
inner.add_edge(from_id, to_id).map_err(py_err)?;
}
Ok(Self { inner })
}
#[staticmethod]
fn from_edges(edges: Vec<(String, String)>) -> PyResult<Self> {
let mut inner = RustDag::new();
for (from, to) in &edges {
let from_id = inner.add_node(from);
let to_id = inner.add_node(to);
inner.add_edge(from_id, to_id).map_err(py_err)?;
}
Ok(Self { inner })
}
fn nodes(&self) -> Vec<String> {
self.inner
.all_nodes()
.map(|id| self.inner.node_name(id).unwrap().to_string())
.collect()
}
fn edges(&self) -> Vec<(String, String)> {
self.inner
.all_nodes()
.flat_map(|from_id| {
let from = self.inner.node_name(from_id).unwrap().to_string();
self.inner
.children_raw(from_id)
.iter()
.map(move |&to_id| {
(
from.clone(),
self.inner.node_name(to_id).unwrap().to_string(),
)
})
.collect::<Vec<_>>()
})
.collect()
}
fn to_dot(&self) -> String {
let mut s = String::from("digraph {\n");
for from_id in self.inner.all_nodes() {
let from = self.inner.node_name(from_id).unwrap();
for &to_id in self.inner.children_raw(from_id) {
let to = self.inner.node_name(to_id).unwrap();
s.push_str(&format!(" {from} -> {to};\n"));
}
}
s.push('}');
s
}
fn add_edge(&mut self, from_name: &str, to_name: &str) -> PyResult<()> {
let from = self.inner.add_node(from_name);
let to = self.inner.add_node(to_name);
self.inner.add_edge(from, to).map_err(py_err)
}
fn validate(&self) -> PyResult<()> {
self.inner.validate().map_err(py_err)
}
}
#[pyclass(name = "ASVExplainer")]
pub struct PyASVExplainer {
inner: AsvExplainer,
names: Vec<String>,
}
impl PyASVExplainer {
fn run(
&self,
value_fn: Py<PyAny>,
method: &str,
n_samples: usize,
seed: Option<u64>,
) -> PyResult<AsvResult> {
let names = &self.names;
let rust_fn = move |coalition: &[NodeId]| -> Result<f64, CausasvError> {
Python::attach(|py| {
let name_list: Vec<&str> = coalition
.iter()
.map(|id| names[id.0 as usize].as_str())
.collect();
value_fn
.call1(py, (name_list,))
.and_then(|r| r.extract::<f64>(py))
.map_err(|e| CausasvError::ValueFunctionError(e.to_string()))
})
};
let make_cfg = || {
let mut cfg = SamplingConfig::new(n_samples);
if let Some(s) = seed {
cfg = cfg.with_seed(s);
}
cfg
};
match method {
"auto" => self.inner.auto(rust_fn, make_cfg()),
"approx" => self.inner.approximate(rust_fn, make_cfg()),
"exact" => self.inner.exact(rust_fn),
"exact_tree" => self.inner.exact_tree(rust_fn),
"exact_dag" => self.inner.exact_dag(rust_fn),
_ => {
return Err(PyValueError::new_err(format!(
"unknown method '{method}': use 'auto', 'approx', 'exact', 'exact_tree', or 'exact_dag'"
)));
}
}
.map_err(py_err)
}
fn values_map(&self, result: &AsvResult) -> HashMap<String, f64> {
result
.values
.iter()
.map(|(id, &v)| (self.names[id.0 as usize].clone(), v))
.collect()
}
}
#[pymethods]
impl PyASVExplainer {
#[new]
fn new(dag: &PyCausalDAG) -> PyResult<Self> {
dag.inner.validate().map_err(py_err)?;
let names: Vec<String> = dag
.inner
.all_nodes()
.map(|id| dag.inner.node_name(id).unwrap().to_string())
.collect();
Ok(Self {
inner: AsvExplainer::new(dag.inner.clone()),
names,
})
}
#[pyo3(signature = (value_fn, method = "auto", n_samples = 10_000, seed = None))]
fn explain(
&self,
_py: Python<'_>,
value_fn: Py<PyAny>,
method: &str,
n_samples: usize,
seed: Option<u64>,
) -> PyResult<HashMap<String, f64>> {
let result = self.run(value_fn, method, n_samples, seed)?;
Ok(self.values_map(&result))
}
#[pyo3(signature = (value_fn, method = "auto", n_samples = 10_000, seed = None))]
fn explain_with_diagnostics<'py>(
&self,
py: Python<'py>,
value_fn: Py<PyAny>,
method: &str,
n_samples: usize,
seed: Option<u64>,
) -> PyResult<Bound<'py, PyDict>> {
let result = self.run(value_fn, method, n_samples, seed)?;
let ess_ratio = result
.effective_sample_size
.map(|e| e / result.n_samples as f64);
let d = PyDict::new(py);
d.set_item("values", self.values_map(&result))?;
d.set_item("ess", result.effective_sample_size)?;
d.set_item("ess_ratio", ess_ratio)?;
d.set_item("n_samples", result.n_samples)?;
d.set_item("seed", result.seed)?;
d.set_item("is_exact", result.is_exact)?;
d.set_item("method", method)?;
Ok(d)
}
#[allow(clippy::too_many_arguments)]
#[pyo3(signature = (value_fn, min_samples=1_000, max_samples=100_000,
batch_size=1_000, rel_tol=0.01, ess_ratio_min=0.10, seed=None))]
fn explain_adaptive<'py>(
&self,
py: Python<'py>,
value_fn: Py<PyAny>,
min_samples: usize,
max_samples: usize,
batch_size: usize,
rel_tol: f64,
ess_ratio_min: f64,
seed: Option<u64>,
) -> PyResult<Bound<'py, PyDict>> {
let names = &self.names;
let rust_fn = move |coalition: &[NodeId]| -> Result<f64, CausasvError> {
Python::attach(|py| {
let name_list: Vec<&str> = coalition
.iter()
.map(|id| names[id.0 as usize].as_str())
.collect();
value_fn
.call1(py, (name_list,))
.and_then(|r| r.extract::<f64>(py))
.map_err(|e| CausasvError::ValueFunctionError(e.to_string()))
})
};
let config = AdaptiveSamplingConfig {
min_samples,
max_samples,
batch_size,
rel_tol,
ess_ratio_min,
seed,
};
let result = self
.inner
.approximate_adaptive(rust_fn, config)
.map_err(py_err)?;
let ess_ratio = result
.effective_sample_size
.map(|e| e / result.n_samples as f64);
let stderr_map: HashMap<String, f64> = result
.stderr
.as_ref()
.map(|m| {
m.iter()
.map(|(id, &v)| (self.names[id.0 as usize].clone(), v))
.collect()
})
.unwrap_or_default();
let d = PyDict::new(py);
d.set_item("values", self.values_map(&result))?;
d.set_item("ess", result.effective_sample_size)?;
d.set_item("ess_ratio", ess_ratio)?;
d.set_item("n_samples", result.n_samples)?;
d.set_item("seed", result.seed)?;
d.set_item("is_exact", result.is_exact)?;
d.set_item("method", "approx_adaptive")?;
d.set_item("converged", result.converged)?;
d.set_item("stderr", stderr_map)?;
Ok(d)
}
}
#[pymodule]
fn causasv(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyCausalDAG>()?;
m.add_class::<PyASVExplainer>()?;
Ok(())
}
fn py_err(e: CausasvError) -> PyErr {
PyValueError::new_err(e.to_string())
}