Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(ui): initial setup to run the webserver and launch the ui #23

Merged
merged 4 commits into from
Jan 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@
__pycache__/
.DS_Store
.coverage

causy/static/*
10 changes: 10 additions & 0 deletions causy/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,11 @@
"""causy

Causal discovery made easy. Causy allows you to use and implement causal discovery algorithms with easy to use, extend and maintain pipelines. It is built based on pytorch which allows you to run the algorithms on CPUs as well as GPUs seamlessly.

Learn more at https://causy.dev.

"""

import importlib.metadata

__version__ = importlib.metadata.version("causy")
31 changes: 11 additions & 20 deletions causy/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
load_pipeline_steps_by_definition,
retrieve_edges,
)
from causy.ui import server

app = typer.Typer()

Expand Down Expand Up @@ -49,13 +50,22 @@ def eject(algorithm: str, output_file: str):
file.write(json.dumps(result, indent=4))


@app.command()
def ui(result_file: str):
result = load_json(result_file)

server_config, server_runner = server(result)
typer.launch(f"http://{server_config.host}:{server_config.port}")
typer.echo(f"🚀 Starting server at http://{server_config.host}:{server_config.port}")
server_runner.run()


@app.command()
def execute(
data_file: str,
pipeline: str = None,
algorithm: str = None,
output_file: str = None,
render_save_file: str = None,
log_level: str = "ERROR",
):
logging.basicConfig(level=log_level)
Expand Down Expand Up @@ -113,25 +123,6 @@ def execute(
}
file.write(json.dumps(export, cls=MyJSONEncoder, indent=4))

if render_save_file:
# I'm just a hacky rendering function, pls replace me with causy ui 🙄
typer.echo(f"💾 Saving graph to {render_save_file}")
import networkx as nx
import matplotlib.pyplot as plt

n_graph = nx.DiGraph()
for u in model.graph.edges:
for v in model.graph.edges[u]:
n_graph.add_edge(model.graph.nodes[u].name, model.graph.nodes[v].name)
fig = plt.figure(figsize=(10, 10))
nx.draw(n_graph, with_labels=True, ax=fig.add_subplot(111))
fig.savefig(render_save_file)


@app.command()
def visualize(output: str):
raise NotImplementedError()


if __name__ == "__main__":
app()
4 changes: 2 additions & 2 deletions causy/common_pipeline_steps/calculation.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ def test(self, nodes: Tuple[str], graph: BaseGraphInterface) -> TestResult:
edge_value["correlation"] = pearson_correlation.item()

return TestResult(
x=x,
y=y,
u=x,
v=y,
action=TestResultAction.UPDATE_EDGE,
data=edge_value,
)
2 changes: 1 addition & 1 deletion causy/common_pipeline_steps/placeholder.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,4 @@ def test(
:return:
"""
logger.debug(f"PlaceholderTest {nodes}")
return TestResult(x=None, y=None, action=TestResultAction.DO_NOTHING, data={})
return TestResult(u=None, v=None, action=TestResultAction.DO_NOTHING, data={})
64 changes: 32 additions & 32 deletions causy/graph_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,75 +131,75 @@ def _take_action(self, results):
result_items = [result_items]

for i in result_items:
if i.x is not None and i.y is not None:
logger.info(f"Action: {i.action} on {i.x.name} and {i.y.name}")
if i.u is not None and i.v is not None:
logger.info(f"Action: {i.action} on {i.u.name} and {i.v.name}")

# execute the action returned by the test
if i.action == TestResultAction.REMOVE_EDGE_UNDIRECTED:
if not self.graph.undirected_edge_exists(i.x, i.y):
if not self.graph.undirected_edge_exists(i.u, i.v):
logger.debug(
f"Tried to remove undirected edge {i.x.name} <-> {i.y.name}. But it does not exist."
f"Tried to remove undirected edge {i.u.name} <-> {i.v.name}. But it does not exist."
)
continue
self.graph.remove_edge(i.x, i.y)
self.graph.add_edge_history(i.x, i.y, i)
self.graph.add_edge_history(i.y, i.x, i)
self.graph.remove_edge(i.u, i.v)
self.graph.add_edge_history(i.u, i.v, i)
self.graph.add_edge_history(i.v, i.u, i)
elif i.action == TestResultAction.UPDATE_EDGE:
if not self.graph.edge_exists(i.x, i.y):
if not self.graph.edge_exists(i.u, i.v):
logger.debug(
f"Tried to update edge {i.x.name} -> {i.y.name}. But it does not exist."
f"Tried to update edge {i.u.name} -> {i.v.name}. But it does not exist."
)
continue
self.graph.update_edge(i.x, i.y, metadata=i.data)
self.graph.add_edge_history(i.x, i.y, i)
self.graph.add_edge_history(i.y, i.x, i)
self.graph.update_edge(i.u, i.v, metadata=i.data)
self.graph.add_edge_history(i.u, i.v, i)
self.graph.add_edge_history(i.v, i.u, i)
elif i.action == TestResultAction.UPDATE_EDGE_DIRECTED:
if not self.graph.directed_edge_exists(i.x, i.y):
if not self.graph.directed_edge_exists(i.u, i.v):
logger.debug(
f"Tried to update directed edge {i.x.name} -> {i.y.name}. But it does not exist."
f"Tried to update directed edge {i.u.name} -> {i.v.name}. But it does not exist."
)
continue
self.graph.update_directed_edge(i.x, i.y, i.data)
self.graph.add_edge_history(i.x, i.y, i)
self.graph.update_directed_edge(i.u, i.v, i.data)
self.graph.add_edge_history(i.u, i.v, i)
elif i.action == TestResultAction.DO_NOTHING:
continue
elif i.action == TestResultAction.REMOVE_EDGE_DIRECTED:
if not self.graph.directed_edge_exists(
i.x, i.y
) and not self.graph.edge_exists(i.x, i.y):
i.u, i.v
) and not self.graph.edge_exists(i.u, i.v):
logger.debug(
f"Tried to remove directed edge {i.x.name} -> {i.y.name}. But it does not exist."
f"Tried to remove directed edge {i.u.name} -> {i.v.name}. But it does not exist."
)
continue

self.graph.remove_directed_edge(i.x, i.y)
self.graph.remove_directed_edge(i.u, i.v)
# TODO: move this to pre/post update hooks
if self.graph.edge_exists(
i.y, i.x
i.v, i.u
): # if the edge is undirected, make it directed
self.graph.update_directed_edge(
i.y, i.x, edge_type=EdgeType.DIRECTED
i.v, i.u, edge_type=EdgeType.DIRECTED
)
self.graph.add_edge_history(i.x, i.y, i)
self.graph.add_edge_history(i.u, i.v, i)

elif i.action == TestResultAction.UPDATE_EDGE_TYPE:
if not self.graph.edge_exists(i.x, i.y):
if not self.graph.edge_exists(i.u, i.v):
logger.debug(
f"Tried to update edge type {i.x.name} <-> {i.y.name}. But it does not exist."
f"Tried to update edge type {i.u.name} <-> {i.v.name}. But it does not exist."
)
continue
self.graph.update_edge(i.x, i.y, edge_type=i.edge_type)
self.graph.add_edge_history(i.x, i.y, i)
self.graph.add_edge_history(i.y, i.x, i)
self.graph.update_edge(i.u, i.v, edge_type=i.edge_type)
self.graph.add_edge_history(i.u, i.v, i)
self.graph.add_edge_history(i.v, i.u, i)

elif i.action == TestResultAction.UPDATE_EDGE_TYPE_DIRECTED:
if not self.graph.directed_edge_exists(i.x, i.y):
if not self.graph.directed_edge_exists(i.u, i.v):
logger.debug(
f"Tried to update edge type {i.x.name} -> {i.y.name}. But it does not exist."
f"Tried to update edge type {i.u.name} -> {i.v.name}. But it does not exist."
)
continue
self.graph.update_directed_edge(i.x, i.y, edge_type=i.edge_type)
self.graph.add_edge_history(i.x, i.y, i)
self.graph.update_directed_edge(i.u, i.v, edge_type=i.edge_type)
self.graph.add_edge_history(i.u, i.v, i)

# add the action to the actions history
actions_taken.append(i)
Expand Down
22 changes: 11 additions & 11 deletions causy/independence_tests/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,14 @@ class CorrelationCoefficientTest(PipelineStepInterface):

def test(self, nodes: List[str], graph: BaseGraphInterface) -> Optional[TestResult]:
"""
Test if x and y are independent and delete edge in graph if they are.
Test if u and v are independent and delete edge in graph if they are.
:param nodes: list of nodes
:return: A TestResult with the action to take
"""
x = graph.nodes[nodes[0]]
y = graph.nodes[nodes[1]]

# make t test for independency of x and y
# make t test for independency of u and v
sample_size = len(x.values)
nb_of_control_vars = 0
corr = graph.edge_value(x, y)["correlation"]
Expand All @@ -45,8 +45,8 @@ def test(self, nodes: List[str], graph: BaseGraphInterface) -> Optional[TestResu
if abs(t) < critical_t:
logger.debug(f"Nodes {x.name} and {y.name} are uncorrelated")
return TestResult(
x=x,
y=y,
u=x,
v=y,
action=TestResultAction.REMOVE_EDGE_UNDIRECTED,
data={},
)
Expand All @@ -63,7 +63,7 @@ def test(
self, nodes: Tuple[str], graph: BaseGraphInterface
) -> Optional[List[TestResult]]:
"""
Test if nodes x,y are independent given node z based on a partial correlation test.
Test if nodes u,v are independent given node z based on a partial correlation test.
We use this test for all combinations of 3 nodes because it is faster than the extended test (which supports combinations of n nodes). We can
use it to remove edges between nodes which are not independent given another node and so reduce the number of combinations for the extended test.
:param nodes: the nodes to test
Expand Down Expand Up @@ -101,7 +101,7 @@ def test(

par_corr = numerator / denominator

# make t test for independency of x and y given z
# make t test for independency of u and v given z
sample_size = len(x.values)
nb_of_control_vars = len(nodes) - 2
t, critical_t = get_t_and_critical_t(
Expand All @@ -115,8 +115,8 @@ def test(

results.append(
TestResult(
x=x,
y=y,
u=x,
v=y,
action=TestResultAction.REMOVE_EDGE_UNDIRECTED,
data={"separatedBy": [z]},
)
Expand All @@ -134,7 +134,7 @@ class ExtendedPartialCorrelationTestMatrix(PipelineStepInterface):

def test(self, nodes: List[str], graph: BaseGraphInterface) -> Optional[TestResult]:
"""
Test if nodes x,y are independent given Z (set of nodes) based on partial correlation using the inverted covariance matrix (precision matrix).
Test if nodes u,v are independent given Z (set of nodes) based on partial correlation using the inverted covariance matrix (precision matrix).
https://en.wikipedia.org/wiki/Partial_correlation#Using_matrix_inversion
We use this test for all combinations of more than 3 nodes because it is slower.
:param nodes: the nodes to test
Expand Down Expand Up @@ -181,8 +181,8 @@ def test(self, nodes: List[str], graph: BaseGraphInterface) -> Optional[TestResu
)
nodes_set = set([graph.nodes[n] for n in nodes])
return TestResult(
x=graph.nodes[nodes[0]],
y=graph.nodes[nodes[1]],
u=graph.nodes[nodes[0]],
v=graph.nodes[nodes[1]],
action=TestResultAction.REMOVE_EDGE_UNDIRECTED,
data={
"separatedBy": list(
Expand Down
16 changes: 7 additions & 9 deletions causy/interfaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,6 @@ class EdgeInterface(SerializeMixin):

def serialize(self):
return {
"u": self.u.serialize(),
"v": self.v.serialize(),
"edge_type": self.edge_type,
"metadata": self.metadata,
}
Expand All @@ -76,15 +74,15 @@ class TestResultAction(enum.StrEnum):

@dataclass
class TestResult(SerializeMixin):
x: NodeInterface
y: NodeInterface
u: NodeInterface
v: NodeInterface
action: TestResultAction
data: Optional[Dict] = None

def serialize(self):
return {
"x": self.x.serialize(),
"y": self.y.serialize(),
"from": self.u.serialize(),
"to": self.v.serialize(),
"action": self.action.name,
}

Expand Down Expand Up @@ -219,9 +217,9 @@ def __init__(
@abstractmethod
def test(self, nodes: List[str], graph: BaseGraphInterface) -> Optional[TestResult]:
"""
Test if x and y are independent
:param x: x values
:param y: y values
Test if u and v are independent
:param u: u values
:param v: v values
:return: True if independent, False otherwise
"""
pass
Expand Down
32 changes: 16 additions & 16 deletions causy/orientation_rules/fci.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,18 @@ def test(
Some notes on how we implment FCI: After the independence tests, we have a graph with undirected edges which are
implemented as two directed edges, one in each direction. We initialize the graph by adding values to all these edges,
in the beginning, they get the value "either directed or undirected". Then we perform the collider test. Unlike in PC,
we do not delete directed edges from z to x and from z to y in order to obtain the structure (x -> z <- y). Instead, we
delete the information "either directed or undirected" from the directed edges from x to z and from y to z. That means,
the directed edges from x to z and from y to z are now truly directed edges. The edges from z to x and from z to y can
we do not delete directed edges from z to u and from z to v in order to obtain the structure (u -> z <- v). Instead, we
delete the information "either directed or undirected" from the directed edges from u to z and from v to z. That means,
the directed edges from u to z and from v to z are now truly directed edges. The edges from z to u and from z to v can
still stand for a directed edge or no directed edge. In the literature, this is portrayed by the meta symbol * and we
obtain x *-> z <-* y. There might be ways to implement these similar but still subtly different orientation rules more consistently.
obtain u *-> z <-* v. There might be ways to implement these similar but still subtly different orientation rules more consistently.

TODO: write tests

We call triples x, y, z of nodes v structures if x and y that are NOT adjacent but share an adjacent node z.
V structures looks like this in the undirected skeleton: (x - z - y).
We now check if z is in the separating set. If so, the edges must be oriented from x to z and from y to z:
(x *-> z <-* y), where * indicated that there can be an arrowhead or none, we do not know, at least until
We call triples u, v, z of nodes v structures if u and v that are NOT adjacent but share an adjacent node z.
V structures looks like this in the undirected skeleton: (u - z - v).
We now check if z is in the separating set. If so, the edges must be oriented from u to z and from v to z:
(u *-> z <-* v), where * indicated that there can be an arrowhead or none, we do not know, at least until
applying further rules.
:param nodes: list of nodes
:param graph: the current graph
Expand All @@ -45,11 +45,11 @@ def test(
x = graph.nodes[nodes[0]]
y = graph.nodes[nodes[1]]

# if x and y are adjacent, do nothing
# if u and v are adjacent, do nothing
if graph.undirected_edge_exists(x, y):
return TestResult(x=x, y=y, action=TestResultAction.DO_NOTHING, data={})
return TestResult(u=x, v=y, action=TestResultAction.DO_NOTHING, data={})

# if x and y are NOT adjacent, store all shared adjacent nodes
# if u and v are NOT adjacent, store all shared adjacent nodes
potential_zs = set(graph.edges[x.id].keys()).intersection(
set(graph.edges[y.id].keys())
)
Expand All @@ -58,7 +58,7 @@ def test(
x, y, TestResultAction.REMOVE_EDGE_UNDIRECTED
)

# if x and y are not independent given z, safe action: make z a collider
# if u and v are not independent given z, safe action: make z a collider
results = []
for z in potential_zs:
z = graph.nodes[z]
Expand All @@ -71,14 +71,14 @@ def test(
if z.id not in separators:
results += [
TestResult(
x=x,
y=z,
u=x,
v=z,
action=TestResultAction.UPDATE_EDGE_DIRECTED,
data={"edge_type": None},
),
TestResult(
x=y,
y=z,
u=y,
v=z,
action=TestResultAction.UPDATE_EDGE_DIRECTED,
data={"edge_type": None},
),
Expand Down
Loading
Loading