statspai.causal_rl¶
causal_rl ¶
Causal Reinforcement Learning (StatsPAI v0.10).
Bridges between RL and causal inference for offline / batch learning scenarios with unobserved confounding.
References
- Li, Zhang & Bareinboim (2025), arXiv 2510.21110 — Confounding-Robust Deep RL.
- Cunha, Liu, French & Mian (2025), arXiv 2512.18135 — Unifying Causal RL.
- Chemingui, Deshwal, Fern, Nguyen-Tang & Doppa (2025), arXiv 2510.22027 — Online Optimization for Offline Safe RL.
CausalDQNResult
dataclass
¶
Bases: ResultProtocolMixin
Output of confounding-robust Q-learning (:func:causal_dqn).
Attributes:
| Name | Type | Description |
|---|---|---|
q_table |
ndarray
|
Learned action-value table, shape |
policy |
ndarray
|
Greedy action for each state, shape |
gamma_bound |
float
|
Confounding bound used during the Bellman updates. |
n_iter |
int
|
Number of value-iteration sweeps performed. |
final_bellman_error |
float
|
Mean squared temporal-difference error at the last iteration. |
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(0)
>>> n = 400
>>> s = rng.integers(0, 3, size=n)
>>> a = rng.integers(0, 2, size=n)
>>> r = (a == s % 2).astype(float) + rng.normal(0, 0.1, size=n)
>>> s_next = rng.integers(0, 3, size=n)
>>> df = pd.DataFrame({'s': s, 'a': a, 'r': r, 's_next': s_next})
>>> res = sp.causal_dqn(df, state='s', action='a', reward='r',
... next_state='s_next', gamma_bound=0.1, n_iter=50)
>>> isinstance(res, sp.CausalDQNResult)
True
>>> res.q_table.shape
(3, 2)
>>> res.gamma_bound
0.1
BanditBenchmarkResult
dataclass
¶
Bases: ResultProtocolMixin
Output from a causal-RL benchmark run.
Returned by :func:causal_rl_benchmark. Holds the generated transition
dataset, the optimal policy/value of the underlying causal model, and the
name of the recommended off-policy evaluator.
Examples:
>>> import statspai as sp
>>> res = sp.causal_rl_benchmark(
... name='confounded_bandit', n_episodes=200, seed=0)
>>> type(res).__name__
'BanditBenchmarkResult'
>>> res.suggested_evaluator
'sp.causal_dqn'
>>> res.transitions['action'].isin([0, 1]).all().item()
True
OfflineSafeResult
dataclass
¶
Bases: ResultProtocolMixin
Output of safe offline policy learning.
Returned by :func:offline_safe_policy. Holds the per-state action
table (policy), the policy's expected reward and cost, the cost
threshold it was constrained against, and whether the realised cost
stays under that threshold (feasible).
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(0)
>>> n = 600
>>> df = pd.DataFrame({
... "state": rng.integers(0, 3, n),
... "action": rng.integers(0, 2, n),
... "reward": rng.integers(0, 2, n) * 1.0 + rng.normal(0, 0.5, n),
... "cost": rng.integers(0, 2, n) * 0.3 + rng.normal(0, 0.1, n),
... })
>>> res = sp.offline_safe_policy(df, state="state", action="action",
... reward="reward", cost="cost",
... cost_threshold=0.5)
>>> isinstance(res, sp.OfflineSafeResult)
True
>>> bool(res.feasible)
True
>>> bool(res.expected_cost <= res.cost_threshold)
True
StructuralMDPResult
dataclass
¶
counterfactual_rollout ¶
counterfactual_rollout(initial_state: ndarray, policy: Callable[[ndarray], ndarray], horizon: int = 10) -> Dict[str, ndarray]
Roll out the fitted SVAR under a new policy to get a
counterfactual (state, action, reward) trajectory.
causal_rl_benchmark ¶
causal_rl_benchmark(name: str = 'confounded_bandit', n_episodes: int = 1000, confounding_strength: float = 0.5, seed: int = 0) -> BanditBenchmarkResult
Generate a synthetic causal-RL benchmark dataset.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
{'confounded_bandit', 'confounded_dosage', 'confounded_pricing',
|
|
'confounded_bandit'
|
n_episodes
|
int
|
|
1000
|
confounding_strength
|
float in [0, 1]
|
Magnitude of unmeasured confounding U → (action, reward). |
0.5
|
seed
|
int
|
|
0
|
Returns:
| Type | Description |
|---|---|
BanditBenchmarkResult
|
|
References
.. [1] cunha2025unifying
Examples:
Generate a confounded two-arm bandit and inspect the transition table:
>>> import statspai as sp
>>> res = sp.causal_rl_benchmark(
... name='confounded_bandit', n_episodes=200, seed=0)
>>> res.benchmark
'confounded_bandit'
>>> len(res.transitions)
200
>>> list(res.transitions.columns)
['state', 'action', 'reward', 'next_state']
>>> res.optimal_value
1.5
>>> res.optimal_policy.tolist()
[1]
>>> print(res.summary())
offline_safe_policy ¶
offline_safe_policy(data: DataFrame, state: str, action: str, reward: str, cost: str, cost_threshold: float = 0.5, discount: float = 0.95, n_iter: int = 100, seed: int = 0) -> OfflineSafeResult
Safe offline policy learning with a cost-constraint.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Transition data (s, a, r, cost). |
required |
state
|
str
|
Column names. state and action must be discrete. |
required |
action
|
str
|
Column names. state and action must be discrete. |
required |
reward
|
str
|
Column names. state and action must be discrete. |
required |
cost
|
str
|
Column names. state and action must be discrete. |
required |
cost_threshold
|
float
|
Max allowed expected cost per step. |
0.5
|
discount
|
float
|
|
0.95
|
n_iter
|
int
|
|
100
|
seed
|
int
|
|
0
|
Returns:
| Type | Description |
|---|---|
OfflineSafeResult
|
|
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(0)
>>> n = 600
>>> df = pd.DataFrame({
... "state": rng.integers(0, 3, n),
... "action": rng.integers(0, 2, n),
... "reward": rng.integers(0, 2, n) * 1.0 + rng.normal(0, 0.5, n),
... "cost": rng.integers(0, 2, n) * 0.3 + rng.normal(0, 0.1, n),
... })
>>> res = sp.offline_safe_policy(df, state="state", action="action",
... reward="reward", cost="cost",
... cost_threshold=0.5)
>>> int(res.policy.shape[0]) # one action per visited state
3
>>> bool(res.feasible)
True
causal_bandit ¶
causal_bandit(arms: Sequence[str], *, reward_fn: Callable[[str, Optional[dict]], float], context: Optional[dict] = None, n_samples: int = 500, rng_seed: int = 0) -> CausalBanditResult
Bareinboim-Forney-Pearl contextual causal bandit.
Given a callable reward_fn(arm, context) that samples the
potential outcome of an arm under the current context, Monte Carlo
estimates E[Y(a) | context] for each arm and returns the
argmax.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
arms
|
sequence of str
|
Arm labels. |
required |
reward_fn
|
callable
|
Stochastic reward sampler. Must accept (arm, context) and return a scalar reward. |
required |
context
|
dict
|
|
None
|
n_samples
|
int
|
Monte Carlo draws per arm. |
500
|
rng_seed
|
int
|
|
0
|
Returns:
| Type | Description |
|---|---|
CausalBanditResult
|
|
Examples:
>>> import statspai as sp
>>> import numpy as np
>>> rng = np.random.default_rng(0)
>>> true = {"A": 1.0, "B": 0.3, "C": 0.6}
>>> def reward_fn(arm, context):
... return true[arm] + rng.normal(0, 0.5)
>>> res = sp.causal_bandit(["A", "B", "C"], reward_fn=reward_fn,
... n_samples=300, rng_seed=0)
>>> res.arm_labels[res.optimal_arm]
'A'
>>> len(res.expected_rewards)
3
counterfactual_policy_optimization ¶
counterfactual_policy_optimization(data: DataFrame, *, state: str, action: str, reward: str, target_policy: Callable[[float], float], noise_sd: float = 1.0) -> CFPolicyResult
Counterfactual policy evaluation under a linear-Gaussian SCM.
Assumes a one-step SCM
r = alpha * s + beta * a + eps, eps ~ Normal(0, noise_sd²)
so that fixing s and changing a uniquely determines a new
reward via noise inversion.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
One row per trajectory; must contain numeric |
required |
state
|
str
|
|
required |
action
|
str
|
|
required |
reward
|
str
|
|
required |
target_policy
|
callable(float) -> float
|
Proposed policy |
required |
noise_sd
|
float
|
|
1.0
|
Returns:
| Type | Description |
|---|---|
CFPolicyResult
|
|
Examples:
>>> import statspai as sp
>>> import numpy as np, pandas as pd
>>> rng = np.random.default_rng(0)
>>> n = 300
>>> s = rng.normal(0, 1, n)
>>> a = 0.5 * s + rng.normal(0, 1, n)
>>> r = 1.0 * s + 2.0 * a + rng.normal(0, 1, n)
>>> df = pd.DataFrame({"s": s, "a": a, "r": r})
>>> res = sp.counterfactual_policy_optimization(
... df, state="s", action="a", reward="r",
... target_policy=lambda si: si + 1.0)
>>> res.n_trajectories
300
>>> bool(np.isfinite(res.improvement))
True
structural_mdp ¶
structural_mdp(data: DataFrame, *, state_cols: Sequence[str], action_cols: Sequence[str], reward: str, next_state_cols: Optional[Sequence[str]] = None, time: Optional[str] = None, trajectory: Optional[str] = None) -> StructuralMDPResult
Fit a linear SVAR for a Markov decision process.
Estimates:
s_{t+1} = A s_t + B a_t + noise
r_t = coef_s @ s_t + coef_a @ a_t
from logged tuples. Supports per-trajectory data (trajectory
column groups consecutive transitions) or single-stream data with a
time column.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
|
required |
state_cols
|
sequence of str
|
|
required |
action_cols
|
sequence of str
|
|
required |
reward
|
str
|
|
required |
next_state_cols
|
sequence of str
|
If present, each row is a complete (s, a, r, s') tuple. If
omitted, the function derives |
None
|
time
|
str
|
Required if |
None
|
trajectory
|
str
|
Trajectory identifier for multi-episode data. |
None
|
Returns:
| Type | Description |
|---|---|
StructuralMDPResult
|
|
Examples:
>>> import statspai as sp
>>> import numpy as np, pandas as pd
>>> rng = np.random.default_rng(0)
>>> n = 200
>>> s1, s2 = rng.normal(0, 1, n), rng.normal(0, 1, n)
>>> a1 = rng.normal(0, 1, n)
>>> df = pd.DataFrame({
... "s1": s1, "s2": s2, "a1": a1,
... "ns1": 0.8 * s1 + 0.2 * a1 + rng.normal(0, 0.1, n),
... "ns2": 0.5 * s2 + 0.3 * a1 + rng.normal(0, 0.1, n),
... "r": 1.0 * s1 + 0.5 * a1 + rng.normal(0, 0.1, n)})
>>> res = sp.structural_mdp(
... df, state_cols=["s1", "s2"], action_cols=["a1"],
... reward="r", next_state_cols=["ns1", "ns2"])
>>> (res.state_dim, res.action_dim)
(2, 1)
>>> res.A.shape
(2, 2)
>>> roll = res.counterfactual_rollout(
... initial_state=[0.0, 0.0], policy=lambda s: np.array([1.0]), horizon=5)
>>> roll["states"].shape
(6, 2)