causing 2.4.5__tar.gz → 2.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {causing-2.4.5 → causing-2.5.0}/PKG-INFO +6 -6
- {causing-2.4.5 → causing-2.5.0}/causing/__init__.py +2 -2
- {causing-2.4.5 → causing-2.5.0}/causing/graph.py +5 -5
- {causing-2.4.5 → causing-2.5.0}/causing/model.py +7 -7
- {causing-2.4.5 → causing-2.5.0}/causing.egg-info/PKG-INFO +6 -6
- {causing-2.4.5 → causing-2.5.0}/causing.egg-info/SOURCES.txt +1 -0
- causing-2.5.0/causing.egg-info/requires.txt +6 -0
- {causing-2.4.5 → causing-2.5.0}/setup.py +10 -6
- causing-2.5.0/tests/examples/models.py +295 -0
- causing-2.5.0/tests/test_estimate.py +174 -0
- causing-2.5.0/tests/test_model.py +452 -0
- causing-2.5.0/tests/utils.py +96 -0
- causing-2.4.5/causing.egg-info/requires.txt +0 -6
- causing-2.4.5/tests/examples/models.py +0 -113
- causing-2.4.5/tests/test_estimate.py +0 -100
- causing-2.4.5/tests/utils.py +0 -18
- {causing-2.4.5 → causing-2.5.0}/LICENSE.md +0 -0
- {causing-2.4.5 → causing-2.5.0}/README.md +0 -0
- {causing-2.4.5 → causing-2.5.0}/causing/utils.py +0 -0
- {causing-2.4.5 → causing-2.5.0}/causing.egg-info/dependency_links.txt +0 -0
- {causing-2.4.5 → causing-2.5.0}/causing.egg-info/top_level.txt +0 -0
- {causing-2.4.5 → causing-2.5.0}/setup.cfg +0 -0
- {causing-2.4.5 → causing-2.5.0}/tests/__init__.py +0 -0
- {causing-2.4.5 → causing-2.5.0}/tests/examples/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: causing
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.5.0
|
|
4
4
|
Summary: Causing: CAUSal INterpretation using Graphs
|
|
5
5
|
Home-page: https://github.com/realrate/Causing
|
|
6
6
|
Author: Dr. Holger Bartel
|
|
@@ -11,11 +11,11 @@ Classifier: Operating System :: OS Independent
|
|
|
11
11
|
Requires-Python: >=3.9
|
|
12
12
|
Description-Content-Type: text/markdown
|
|
13
13
|
License-File: LICENSE.md
|
|
14
|
-
Requires-Dist: numpy
|
|
15
|
-
Requires-Dist: pandas
|
|
16
|
-
Requires-Dist: scipy
|
|
17
|
-
Requires-Dist: sympy
|
|
18
|
-
Requires-Dist: networkx
|
|
14
|
+
Requires-Dist: numpy>=1.23
|
|
15
|
+
Requires-Dist: pandas>=1.3
|
|
16
|
+
Requires-Dist: scipy>=1.9
|
|
17
|
+
Requires-Dist: sympy>=1.5
|
|
18
|
+
Requires-Dist: networkx>=2.7
|
|
19
19
|
Requires-Dist: pre-commit
|
|
20
20
|
Dynamic: author
|
|
21
21
|
Dynamic: author-email
|
|
@@ -14,8 +14,8 @@ def create_indiv(m: Model, xdat, show_nr_indiv: int) -> dict:
|
|
|
14
14
|
data for tests cases. Otherwise, use `Model.calc_effects` directly.
|
|
15
15
|
"""
|
|
16
16
|
eff = m.calc_effects(xdat)
|
|
17
|
-
for key in ["
|
|
18
|
-
if key in ["
|
|
17
|
+
for key in ["xnodeeffects", "ynodeeffects", "xedgeeffects", "yedgeeffects"]:
|
|
18
|
+
if key in ["xnodeeffects", "ynodeeffects"]:
|
|
19
19
|
eff[key] = eff[key][:, :show_nr_indiv]
|
|
20
20
|
else:
|
|
21
21
|
eff[key] = eff[key][:show_nr_indiv]
|
|
@@ -83,13 +83,13 @@ def annotated_graphs(
|
|
|
83
83
|
) -> Iterable[networkx.DiGraph]:
|
|
84
84
|
"""Return DiGraphs with all information required to draw IME graphs"""
|
|
85
85
|
if ids is None:
|
|
86
|
-
ids = [str(i + 1) for i in range(len(graph_json["
|
|
86
|
+
ids = [str(i + 1) for i in range(len(graph_json["xedgeeffects"]))]
|
|
87
87
|
for graph_id, exj, eyj, eyx, eyy in zip(
|
|
88
88
|
ids,
|
|
89
|
-
np.array(graph_json["
|
|
90
|
-
np.array(graph_json["
|
|
91
|
-
graph_json["
|
|
92
|
-
graph_json["
|
|
89
|
+
np.array(graph_json["xnodeeffects"]).T,
|
|
90
|
+
np.array(graph_json["ynodeeffects"]).T,
|
|
91
|
+
graph_json["xedgeeffects"],
|
|
92
|
+
graph_json["yedgeeffects"],
|
|
93
93
|
):
|
|
94
94
|
g = m.graph.copy()
|
|
95
95
|
g.graph["id"] = graph_id
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from dataclasses import dataclass, field
|
|
4
|
-
from typing import
|
|
4
|
+
from typing import Sequence, Callable
|
|
5
5
|
from functools import cached_property
|
|
6
6
|
import networkx
|
|
7
7
|
|
|
@@ -18,7 +18,7 @@ class Model:
|
|
|
18
18
|
|
|
19
19
|
xvars: list[str]
|
|
20
20
|
yvars: list[str]
|
|
21
|
-
equations:
|
|
21
|
+
equations: Sequence[sympy.Expr]
|
|
22
22
|
final_var: str
|
|
23
23
|
parameters: dict[str, float] = field(default_factory=dict)
|
|
24
24
|
|
|
@@ -180,15 +180,15 @@ class Model:
|
|
|
180
180
|
# model results
|
|
181
181
|
"yhat": yhat,
|
|
182
182
|
# nodes
|
|
183
|
-
"
|
|
184
|
-
"
|
|
183
|
+
"xnodeeffects": exj,
|
|
184
|
+
"ynodeeffects": eyj,
|
|
185
185
|
# edges
|
|
186
|
-
"
|
|
187
|
-
"
|
|
186
|
+
"xedgeeffects": eyx,
|
|
187
|
+
"yedgeeffects": eyy,
|
|
188
188
|
}
|
|
189
189
|
|
|
190
190
|
@cached_property
|
|
191
|
-
def _model_lam(self) ->
|
|
191
|
+
def _model_lam(self) -> Sequence[Callable]:
|
|
192
192
|
"""Create lambdified equations with NumPy-compatible functions."""
|
|
193
193
|
lambdas = []
|
|
194
194
|
ordered_vars = self.vars + list(self.parameters.keys())
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: causing
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.5.0
|
|
4
4
|
Summary: Causing: CAUSal INterpretation using Graphs
|
|
5
5
|
Home-page: https://github.com/realrate/Causing
|
|
6
6
|
Author: Dr. Holger Bartel
|
|
@@ -11,11 +11,11 @@ Classifier: Operating System :: OS Independent
|
|
|
11
11
|
Requires-Python: >=3.9
|
|
12
12
|
Description-Content-Type: text/markdown
|
|
13
13
|
License-File: LICENSE.md
|
|
14
|
-
Requires-Dist: numpy
|
|
15
|
-
Requires-Dist: pandas
|
|
16
|
-
Requires-Dist: scipy
|
|
17
|
-
Requires-Dist: sympy
|
|
18
|
-
Requires-Dist: networkx
|
|
14
|
+
Requires-Dist: numpy>=1.23
|
|
15
|
+
Requires-Dist: pandas>=1.3
|
|
16
|
+
Requires-Dist: scipy>=1.9
|
|
17
|
+
Requires-Dist: sympy>=1.5
|
|
18
|
+
Requires-Dist: networkx>=2.7
|
|
19
19
|
Requires-Dist: pre-commit
|
|
20
20
|
Dynamic: author
|
|
21
21
|
Dynamic: author-email
|
|
@@ -5,7 +5,7 @@ with open("README.md", "r", encoding="utf-8") as fh:
|
|
|
5
5
|
|
|
6
6
|
setuptools.setup(
|
|
7
7
|
name="causing",
|
|
8
|
-
version="2.
|
|
8
|
+
version="2.5.0",
|
|
9
9
|
author="Dr. Holger Bartel",
|
|
10
10
|
author_email="holger.bartel@realrate.ai",
|
|
11
11
|
description="Causing: CAUSal INterpretation using Graphs",
|
|
@@ -19,11 +19,15 @@ setuptools.setup(
|
|
|
19
19
|
"Operating System :: OS Independent",
|
|
20
20
|
],
|
|
21
21
|
install_requires=[
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
"
|
|
22
|
+
# Lower-bound floors only: the previous `~=` upper caps blocked numpy 2 /
|
|
23
|
+
# pandas 2+ / networkx 3 and made Python 3.14 impossible (no pandas 1.x cp314
|
|
24
|
+
# wheels). Verified numerically equivalent across the stack jump — see
|
|
25
|
+
# https://github.com/realrate/RealRate-Private/issues/2098
|
|
26
|
+
"numpy>=1.23",
|
|
27
|
+
"pandas>=1.3",
|
|
28
|
+
"scipy>=1.9",
|
|
29
|
+
"sympy>=1.5",
|
|
30
|
+
"networkx>=2.7",
|
|
27
31
|
"pre-commit", # TODO: move to dev-requirements
|
|
28
32
|
],
|
|
29
33
|
python_requires=">=3.9",
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
import numpy as np
|
|
3
|
+
from sympy import symbols, Matrix
|
|
4
|
+
|
|
5
|
+
from causing.examples.models import example, education
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def compute_theo_effects(m, xpoint):
|
|
9
|
+
"""
|
|
10
|
+
Compute theoretical effects at a given point using analytical derivatives.
|
|
11
|
+
This recreates the functionality of the old theo() method.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
m: Model object
|
|
15
|
+
xpoint: 1-D array of x values at which to evaluate, with length m.mdim
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
Dictionary with effect matrices (mx_theo, my_theo, ex_theo, ey_theo,
|
|
19
|
+
exj_theo, eyj_theo, eyx_theo, eyy_theo)
|
|
20
|
+
|
|
21
|
+
Notes:
|
|
22
|
+
- Uses symbolic differentiation via sympy to compute Jacobian matrices
|
|
23
|
+
- Solves for total effects using matrix inversion: (I - dY/dY)^(-1) * dY/dX
|
|
24
|
+
- Falls back to a least-squares approximate solution if the system is singular or rank-deficient
|
|
25
|
+
"""
|
|
26
|
+
# Create symbolic variables
|
|
27
|
+
xvars_sym = symbols(m.xvars)
|
|
28
|
+
yvars_sym = symbols(m.yvars)
|
|
29
|
+
|
|
30
|
+
# Compute ypoint
|
|
31
|
+
ypoint = m.compute(xpoint.reshape(-1, 1)).flatten()
|
|
32
|
+
point_dict = {str(xvars_sym[i]): xpoint[i] for i in range(len(xvars_sym))}
|
|
33
|
+
point_dict.update({str(yvars_sym[i]): ypoint[i] for i in range(len(yvars_sym))})
|
|
34
|
+
|
|
35
|
+
# Create vectors for differentiation
|
|
36
|
+
xvec = Matrix(xvars_sym)
|
|
37
|
+
yvec = Matrix(yvars_sym)
|
|
38
|
+
eq_vec = Matrix(list(m.equations))
|
|
39
|
+
|
|
40
|
+
# Compute Jacobian matrices
|
|
41
|
+
# mx_theo: dY/dX direct (partial derivatives)
|
|
42
|
+
mx_jacob = eq_vec.jacobian(xvec)
|
|
43
|
+
mx_theo = np.array(mx_jacob.subs(point_dict)).astype(np.float64)
|
|
44
|
+
|
|
45
|
+
# my_theo: dY/dY direct (partial derivatives)
|
|
46
|
+
my_jacob = eq_vec.jacobian(yvec)
|
|
47
|
+
my_theo = np.array(my_jacob.subs(point_dict)).astype(np.float64)
|
|
48
|
+
|
|
49
|
+
# For total effects, solve: (I - dY/dY) * dY/dX_total = dY/dX_direct
|
|
50
|
+
matrix_I = np.eye(m.ndim)
|
|
51
|
+
try:
|
|
52
|
+
ex_theo = np.linalg.solve(matrix_I - my_theo, mx_theo)
|
|
53
|
+
except np.linalg.LinAlgError:
|
|
54
|
+
ex_theo = np.linalg.lstsq(matrix_I - my_theo, mx_theo, rcond=None)[0]
|
|
55
|
+
|
|
56
|
+
# ey_theo: total effects of Y on Y
|
|
57
|
+
try:
|
|
58
|
+
ey_theo = np.linalg.solve(matrix_I - my_theo, matrix_I)
|
|
59
|
+
except np.linalg.LinAlgError:
|
|
60
|
+
ey_theo = np.linalg.lstsq(matrix_I - my_theo, matrix_I, rcond=None)[0]
|
|
61
|
+
|
|
62
|
+
# Final effects (on the final variable)
|
|
63
|
+
final_ind = m.yvars.index(m.final_var)
|
|
64
|
+
xnodeeffect_theo = ex_theo[final_ind, :]
|
|
65
|
+
ynodeeffect_theo = ey_theo[final_ind, :]
|
|
66
|
+
|
|
67
|
+
# Mediation effects
|
|
68
|
+
# xedgeeffect: mediation through Y for each X->Y edge
|
|
69
|
+
# xedgeeffect[y, x] represents the effect of X on the final variable, mediated through Y
|
|
70
|
+
# Formula: xedgeeffect[y, x] = mx[y, x] * ynodeeffect[y]
|
|
71
|
+
xedgeeffect_theo = np.full((m.ndim, m.mdim), np.nan)
|
|
72
|
+
for yind in range(m.ndim):
|
|
73
|
+
for xind in range(m.mdim):
|
|
74
|
+
if mx_theo[yind, xind] != 0 and not np.isnan(mx_theo[yind, xind]):
|
|
75
|
+
xedgeeffect_theo[yind, xind] = (
|
|
76
|
+
mx_theo[yind, xind] * ynodeeffect_theo[yind]
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# yedgeeffect: mediation through Y->Y edges
|
|
80
|
+
# yedgeeffect[y2, y1] represents the effect of Y1 on the final variable, mediated through the Y1->Y2 edge
|
|
81
|
+
# Formula: yedgeeffect[y2, y1] = my[y2, y1] * ynodeeffect[y2]
|
|
82
|
+
yedgeeffect_theo = np.full((m.ndim, m.ndim), np.nan)
|
|
83
|
+
for yind1 in range(m.ndim):
|
|
84
|
+
for yind2 in range(m.ndim):
|
|
85
|
+
if my_theo[yind2, yind1] != 0 and not np.isnan(my_theo[yind2, yind1]):
|
|
86
|
+
yedgeeffect_theo[yind2, yind1] = (
|
|
87
|
+
my_theo[yind2, yind1] * ynodeeffect_theo[yind2]
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Replace 0 with NaN where there's no edge in the graph
|
|
91
|
+
for yind in range(m.ndim):
|
|
92
|
+
for xind in range(m.mdim):
|
|
93
|
+
if not m.graph.has_edge(m.xvars[xind], m.yvars[yind]):
|
|
94
|
+
mx_theo[yind, xind] = np.nan
|
|
95
|
+
xedgeeffect_theo[yind, xind] = np.nan
|
|
96
|
+
# Also set ex_theo to NaN where there's no transitive path
|
|
97
|
+
if not m.trans_graph.has_edge(m.xvars[xind], m.yvars[yind]):
|
|
98
|
+
ex_theo[yind, xind] = np.nan
|
|
99
|
+
|
|
100
|
+
for yind1 in range(m.ndim):
|
|
101
|
+
for yind2 in range(m.ndim):
|
|
102
|
+
if not m.graph.has_edge(m.yvars[yind1], m.yvars[yind2]):
|
|
103
|
+
my_theo[yind2, yind1] = np.nan
|
|
104
|
+
yedgeeffect_theo[yind2, yind1] = np.nan
|
|
105
|
+
# Also set ey_theo to NaN where there's no transitive path
|
|
106
|
+
if not m.trans_graph.has_edge(m.yvars[yind1], m.yvars[yind2]):
|
|
107
|
+
ey_theo[yind2, yind1] = np.nan
|
|
108
|
+
|
|
109
|
+
# Set to NaN where there's no path to final var
|
|
110
|
+
for xind in range(m.mdim):
|
|
111
|
+
if not m.trans_graph.has_edge(m.xvars[xind], m.final_var):
|
|
112
|
+
xnodeeffect_theo[xind] = np.nan
|
|
113
|
+
|
|
114
|
+
for yind in range(m.ndim):
|
|
115
|
+
if not m.trans_graph.has_edge(m.yvars[yind], m.final_var):
|
|
116
|
+
ynodeeffect_theo[yind] = np.nan
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
"mx_theo": mx_theo,
|
|
120
|
+
"my_theo": my_theo,
|
|
121
|
+
"ex_theo": ex_theo,
|
|
122
|
+
"ey_theo": ey_theo,
|
|
123
|
+
"xnodeeffect_theo": xnodeeffect_theo,
|
|
124
|
+
"ynodeeffect_theo": ynodeeffect_theo,
|
|
125
|
+
"xedgeeffect_theo": xedgeeffect_theo,
|
|
126
|
+
"yedgeeffect_theo": yedgeeffect_theo,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class TestExampleModels(unittest.TestCase):
|
|
131
|
+
def test_example(self):
|
|
132
|
+
"""Checks coefficient matrices for direct, total and final effects of example."""
|
|
133
|
+
m, xdat = example()
|
|
134
|
+
generated_theo = compute_theo_effects(m, xdat.mean(axis=1))
|
|
135
|
+
|
|
136
|
+
# direct effects
|
|
137
|
+
mx_theo = np.array([[1, "NaN"], ["NaN", 1], ["NaN", "NaN"]]).astype(np.float64)
|
|
138
|
+
my_theo = np.array(
|
|
139
|
+
[["NaN", "NaN", "NaN"], [11.92914837, "NaN", "NaN"], [1, 1, "NaN"]]
|
|
140
|
+
).astype(np.float64)
|
|
141
|
+
|
|
142
|
+
# total effects
|
|
143
|
+
ex_theo = np.array([[1, "NaN"], [11.92914837, 1], [12.92914837, 1]]).astype(
|
|
144
|
+
np.float64
|
|
145
|
+
)
|
|
146
|
+
ey_theo = np.array(
|
|
147
|
+
[[1, "NaN", "NaN"], [11.92914837, 1, "NaN"], [12.92914837, 1, 1]]
|
|
148
|
+
).astype(np.float64)
|
|
149
|
+
|
|
150
|
+
# final effects
|
|
151
|
+
xnodeeffect_theo = np.array([12.92914837, 1]).astype(np.float64)
|
|
152
|
+
ynodeeffect_theo = np.array([12.92914837, 1, 1]).astype(np.float64)
|
|
153
|
+
xedgeeffect_theo = np.array(
|
|
154
|
+
[[12.92914837, "NaN"], ["NaN", 1], ["NaN", "NaN"]]
|
|
155
|
+
).astype(np.float64)
|
|
156
|
+
yedgeeffect_theo = np.array(
|
|
157
|
+
[["NaN", "NaN", "NaN"], [11.92914837, "NaN", "NaN"], [1, 1, "NaN"]]
|
|
158
|
+
).astype(np.float64)
|
|
159
|
+
|
|
160
|
+
expected_theo = dict(
|
|
161
|
+
mx_theo=mx_theo,
|
|
162
|
+
my_theo=my_theo,
|
|
163
|
+
ex_theo=ex_theo,
|
|
164
|
+
ey_theo=ey_theo,
|
|
165
|
+
xnodeeffect_theo=xnodeeffect_theo,
|
|
166
|
+
ynodeeffect_theo=ynodeeffect_theo,
|
|
167
|
+
xedgeeffect_theo=xedgeeffect_theo,
|
|
168
|
+
yedgeeffect_theo=yedgeeffect_theo,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
for k in expected_theo.keys():
|
|
172
|
+
self.assertIsNone(
|
|
173
|
+
np.testing.assert_array_almost_equal(
|
|
174
|
+
generated_theo[k], expected_theo[k]
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
def test_education(self):
|
|
179
|
+
"""Checks coefficient matrices for direct, total and final effects of education example."""
|
|
180
|
+
m, xdat = education()
|
|
181
|
+
generated_theo = compute_theo_effects(m, xdat.mean(axis=1))
|
|
182
|
+
|
|
183
|
+
# direct effects
|
|
184
|
+
mx_theo = np.array(
|
|
185
|
+
[
|
|
186
|
+
[0.1, 0.1, -0.1, -0.5, "NaN", "NaN"],
|
|
187
|
+
["NaN", "NaN", "NaN", "NaN", "NaN", 1.0],
|
|
188
|
+
["NaN", "NaN", "NaN", "NaN", 1, "NaN"],
|
|
189
|
+
]
|
|
190
|
+
).astype(np.float64)
|
|
191
|
+
my_theo = np.array(
|
|
192
|
+
[["NaN", "NaN", "NaN"], [-1, "NaN", "NaN"], [1.0, 0.5, "NaN"]]
|
|
193
|
+
).astype(np.float64)
|
|
194
|
+
|
|
195
|
+
# total effects
|
|
196
|
+
ex_theo = np.array(
|
|
197
|
+
[
|
|
198
|
+
[0.1, 0.1, -0.1, -0.5, "NaN", "NaN"],
|
|
199
|
+
[-0.1, -0.1, 0.1, 0.5, "NaN", 1],
|
|
200
|
+
[0.05, 0.05, -0.05, -0.25, 1, 0.5],
|
|
201
|
+
]
|
|
202
|
+
).astype(np.float64)
|
|
203
|
+
ey_theo = np.array([[1, "NaN", "NaN"], [-1, 1, "NaN"], [0.5, 0.5, 1]]).astype(
|
|
204
|
+
np.float64
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# final effects
|
|
208
|
+
xnodeeffect_theo = np.array([0.05, 0.05, -0.05, -0.25, 1, 0.5]).astype(
|
|
209
|
+
np.float64
|
|
210
|
+
)
|
|
211
|
+
ynodeeffect_theo = np.array([0.5, 0.5, 1]).astype(np.float64)
|
|
212
|
+
xedgeeffect_theo = np.array(
|
|
213
|
+
[
|
|
214
|
+
[0.05, 0.05, -0.05, -0.25, "NaN", "NaN"],
|
|
215
|
+
["NaN", "NaN", "NaN", "NaN", "NaN", 0.5],
|
|
216
|
+
["NaN", "NaN", "NaN", "NaN", 1, "NaN"],
|
|
217
|
+
]
|
|
218
|
+
).astype(np.float64)
|
|
219
|
+
yedgeeffect_theo = np.array(
|
|
220
|
+
[["NaN", "NaN", "NaN"], [-0.5, "NaN", "NaN"], [1, 0.5, "NaN"]]
|
|
221
|
+
).astype(np.float64)
|
|
222
|
+
|
|
223
|
+
expected_theo = dict(
|
|
224
|
+
mx_theo=mx_theo,
|
|
225
|
+
my_theo=my_theo,
|
|
226
|
+
ex_theo=ex_theo,
|
|
227
|
+
ey_theo=ey_theo,
|
|
228
|
+
xnodeeffect_theo=xnodeeffect_theo,
|
|
229
|
+
ynodeeffect_theo=ynodeeffect_theo,
|
|
230
|
+
xedgeeffect_theo=xedgeeffect_theo,
|
|
231
|
+
yedgeeffect_theo=yedgeeffect_theo,
|
|
232
|
+
)
|
|
233
|
+
for k in expected_theo.keys():
|
|
234
|
+
self.assertIsNone(
|
|
235
|
+
np.testing.assert_array_almost_equal(
|
|
236
|
+
generated_theo[k], expected_theo[k]
|
|
237
|
+
)
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
def test_example2_runs(self):
|
|
241
|
+
"""Test that example2 model runs without errors."""
|
|
242
|
+
from causing.examples.models import example2
|
|
243
|
+
|
|
244
|
+
m, xdat = example2()
|
|
245
|
+
|
|
246
|
+
# Verify model structure
|
|
247
|
+
self.assertEqual(len(m.xvars), 1)
|
|
248
|
+
self.assertEqual(len(m.yvars), 1)
|
|
249
|
+
|
|
250
|
+
# Verify computation works
|
|
251
|
+
yhat = m.compute(xdat)
|
|
252
|
+
self.assertEqual(yhat.shape[0], 1) # 1 y variable
|
|
253
|
+
|
|
254
|
+
# Verify effects calculation works
|
|
255
|
+
effects = m.calc_effects(xdat)
|
|
256
|
+
self.assertIn("yhat", effects)
|
|
257
|
+
|
|
258
|
+
def test_example3_runs(self):
|
|
259
|
+
"""Test that example3 model runs without errors."""
|
|
260
|
+
from causing.examples.models import example3
|
|
261
|
+
|
|
262
|
+
m, xdat = example3()
|
|
263
|
+
|
|
264
|
+
# Verify model structure
|
|
265
|
+
self.assertEqual(len(m.xvars), 1)
|
|
266
|
+
self.assertEqual(len(m.yvars), 3)
|
|
267
|
+
|
|
268
|
+
# Verify computation works
|
|
269
|
+
yhat = m.compute(xdat)
|
|
270
|
+
self.assertEqual(yhat.shape[0], 3) # 3 y variables
|
|
271
|
+
|
|
272
|
+
# Verify effects calculation works
|
|
273
|
+
effects = m.calc_effects(xdat)
|
|
274
|
+
self.assertIn("yhat", effects)
|
|
275
|
+
|
|
276
|
+
def test_heaviside_runs(self):
|
|
277
|
+
"""Test that heaviside model runs without errors."""
|
|
278
|
+
from causing.examples.models import heaviside
|
|
279
|
+
|
|
280
|
+
m, xdat = heaviside()
|
|
281
|
+
|
|
282
|
+
# Verify model structure
|
|
283
|
+
self.assertEqual(len(m.xvars), 1)
|
|
284
|
+
self.assertEqual(len(m.yvars), 1)
|
|
285
|
+
|
|
286
|
+
# Verify computation works
|
|
287
|
+
yhat = m.compute(xdat)
|
|
288
|
+
self.assertEqual(yhat.shape[0], 1) # 1 y variable
|
|
289
|
+
|
|
290
|
+
# Verify heaviside function behavior (Max(X1, 0))
|
|
291
|
+
# xdat should have negative and positive values
|
|
292
|
+
# Negative values should become 0, positive stay positive
|
|
293
|
+
for i in range(xdat.shape[1]):
|
|
294
|
+
expected = max(xdat[0, i], 0)
|
|
295
|
+
self.assertAlmostEqual(yhat[0, i], expected)
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from sympy import symbols
|
|
5
|
+
|
|
6
|
+
# causing.bias module no longer exists in the current codebase
|
|
7
|
+
# These tests have been rewritten to verify model prediction accuracy
|
|
8
|
+
# without the bias estimation functionality
|
|
9
|
+
from causing.model import Model
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TestModelPredictionAccuracy(unittest.TestCase):
|
|
13
|
+
"""Tests that verify model predictions match expected values.
|
|
14
|
+
|
|
15
|
+
Replaces the old causing.bias tests which estimated bias parameters.
|
|
16
|
+
These tests now directly verify that the model produces expected outputs
|
|
17
|
+
when given specific inputs, ensuring the core causal model works correctly.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
X1, X2, Y1, Y2, Y3 = symbols(["X1", "X2", "Y1", "Y2", "Y3"])
|
|
21
|
+
equations = (
|
|
22
|
+
X1,
|
|
23
|
+
X2 + 2 * Y1,
|
|
24
|
+
Y1 + Y2,
|
|
25
|
+
)
|
|
26
|
+
m = Model(
|
|
27
|
+
xvars=[X1, X2],
|
|
28
|
+
yvars=[Y1, Y2, Y3],
|
|
29
|
+
equations=equations,
|
|
30
|
+
final_var=Y3,
|
|
31
|
+
)
|
|
32
|
+
xdat = np.array(
|
|
33
|
+
[
|
|
34
|
+
[1, 1, 1.01, 1.02, 0.99],
|
|
35
|
+
[1, 1.01, 1, 1.03, 0.98],
|
|
36
|
+
]
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
def test_model_predictions_unbiased(self):
|
|
40
|
+
"""Test that model produces correct predictions for unbiased case.
|
|
41
|
+
|
|
42
|
+
When input data follows the model equations exactly, the model
|
|
43
|
+
should predict the final variable accurately.
|
|
44
|
+
"""
|
|
45
|
+
# Compute model predictions
|
|
46
|
+
yhat = self.m.compute(self.xdat)
|
|
47
|
+
|
|
48
|
+
# For the equations Y1=X1, Y2=X2+2*Y1, Y3=Y1+Y2
|
|
49
|
+
# Expected Y3 = Y1 + Y2 = X1 + (X2 + 2*X1) = 3*X1 + X2
|
|
50
|
+
expected_y3 = 3 * self.xdat[0] + self.xdat[1]
|
|
51
|
+
|
|
52
|
+
# Verify predictions match expected values (within numerical precision)
|
|
53
|
+
np.testing.assert_array_almost_equal(yhat[2], expected_y3, decimal=10)
|
|
54
|
+
|
|
55
|
+
# If predictions match observations, there's no bias
|
|
56
|
+
observed_y3 = np.array([4, 4, 4, 3.9, 4.01])
|
|
57
|
+
prediction_errors = yhat[2] - observed_y3
|
|
58
|
+
|
|
59
|
+
# For unbiased data, prediction errors should be small
|
|
60
|
+
self.assertLess(np.abs(prediction_errors).max(), 0.2)
|
|
61
|
+
|
|
62
|
+
def test_model_predictions_with_offset(self):
|
|
63
|
+
"""Test that model can detect when observations differ from predictions.
|
|
64
|
+
|
|
65
|
+
When observations include a bias/offset, the model predictions will
|
|
66
|
+
differ from observations, indicating the presence of unmodeled effects.
|
|
67
|
+
"""
|
|
68
|
+
# Compute model predictions
|
|
69
|
+
yhat = self.m.compute(self.xdat)
|
|
70
|
+
|
|
71
|
+
# Observed data with a systematic offset (bias of +1)
|
|
72
|
+
observed_y3_biased = np.array([5, 5, 5, 4.9, 5.01])
|
|
73
|
+
prediction_errors = observed_y3_biased - yhat[2]
|
|
74
|
+
|
|
75
|
+
# The prediction errors should show a consistent bias
|
|
76
|
+
mean_error = np.mean(prediction_errors)
|
|
77
|
+
|
|
78
|
+
# Mean error should be close to the bias we introduced (1.0)
|
|
79
|
+
self.assertAlmostEqual(mean_error, 1.0, places=1)
|
|
80
|
+
|
|
81
|
+
# All errors should be relatively consistent (similar magnitude)
|
|
82
|
+
std_error = np.std(prediction_errors)
|
|
83
|
+
self.assertLess(std_error, 0.2)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class TestModelWithParametricBias(unittest.TestCase):
|
|
87
|
+
"""Tests that verify models with parametric bias terms work correctly.
|
|
88
|
+
|
|
89
|
+
Replaces the old bias invariance tests. These verify that when bias
|
|
90
|
+
parameters are explicitly included in the model equations, the model
|
|
91
|
+
still computes correctly.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
xdat = np.array(
|
|
95
|
+
[
|
|
96
|
+
[1, 1, 1.01, 1.02, 0.99],
|
|
97
|
+
[1, 1.01, 1, 1.03, 0.98],
|
|
98
|
+
]
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def test_model_with_additive_bias_parameter(self):
|
|
102
|
+
"""Test that models with explicit bias parameters compute correctly."""
|
|
103
|
+
X1, X2, Y1, Y2, Y3 = symbols(["X1", "X2", "Y1", "Y2", "Y3"])
|
|
104
|
+
|
|
105
|
+
# Test with different bias values
|
|
106
|
+
for bias_value in (0, 10, 100):
|
|
107
|
+
with self.subTest(bias=bias_value):
|
|
108
|
+
# Model with explicit bias parameter in Y2 equation
|
|
109
|
+
equations = (
|
|
110
|
+
X1,
|
|
111
|
+
bias_value + X2 + 2 * Y1,
|
|
112
|
+
Y1 + Y2,
|
|
113
|
+
)
|
|
114
|
+
m = Model(
|
|
115
|
+
xvars=[X1, X2],
|
|
116
|
+
yvars=[Y1, Y2, Y3],
|
|
117
|
+
equations=equations,
|
|
118
|
+
final_var=Y3,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# Compute predictions
|
|
122
|
+
yhat = m.compute(self.xdat)
|
|
123
|
+
|
|
124
|
+
# Y1 = X1
|
|
125
|
+
# Y2 = bias + X2 + 2*Y1 = bias + X2 + 2*X1
|
|
126
|
+
# Y3 = Y1 + Y2 = X1 + bias + X2 + 2*X1 = bias + 3*X1 + X2
|
|
127
|
+
expected_y3 = bias_value + 3 * self.xdat[0] + self.xdat[1]
|
|
128
|
+
|
|
129
|
+
# Verify the bias parameter affects predictions as expected
|
|
130
|
+
np.testing.assert_array_almost_equal(yhat[2], expected_y3, decimal=10)
|
|
131
|
+
|
|
132
|
+
# Verify Y2 contains the bias
|
|
133
|
+
expected_y2 = bias_value + self.xdat[1] + 2 * self.xdat[0]
|
|
134
|
+
np.testing.assert_array_almost_equal(yhat[1], expected_y2, decimal=10)
|
|
135
|
+
|
|
136
|
+
def test_model_with_constant_bias(self):
|
|
137
|
+
"""Test models with constant bias terms in equations.
|
|
138
|
+
|
|
139
|
+
Note: This test is skipped because constant equations are not
|
|
140
|
+
currently supported by the Model implementation.
|
|
141
|
+
"""
|
|
142
|
+
self.skipTest("Constant equations not supported in current implementation")
|
|
143
|
+
|
|
144
|
+
X1, X2, Y1, Y2 = symbols(["X1", "X2", "Y1", "Y2"])
|
|
145
|
+
|
|
146
|
+
for bias_value in (0, 5, 20):
|
|
147
|
+
with self.subTest(bias=bias_value):
|
|
148
|
+
# Model with constant bias
|
|
149
|
+
equations = (
|
|
150
|
+
bias_value + 3,
|
|
151
|
+
1 / Y1,
|
|
152
|
+
)
|
|
153
|
+
m = Model(
|
|
154
|
+
xvars=[X1, X2],
|
|
155
|
+
yvars=[Y1, Y2],
|
|
156
|
+
equations=equations,
|
|
157
|
+
final_var=Y2,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# Compute predictions
|
|
161
|
+
yhat = m.compute(self.xdat)
|
|
162
|
+
|
|
163
|
+
# Y1 = bias + 3 (constant)
|
|
164
|
+
# Y2 = 1/Y1 = 1/(bias + 3)
|
|
165
|
+
expected_y1 = bias_value + 3
|
|
166
|
+
expected_y2 = 1 / expected_y1
|
|
167
|
+
|
|
168
|
+
# Verify constant bias is handled correctly
|
|
169
|
+
np.testing.assert_array_almost_equal(
|
|
170
|
+
yhat[0], np.full_like(yhat[0], expected_y1), decimal=10
|
|
171
|
+
)
|
|
172
|
+
np.testing.assert_array_almost_equal(
|
|
173
|
+
yhat[1], np.full_like(yhat[1], expected_y2), decimal=10
|
|
174
|
+
)
|