# CD-NOD augments the data matrix by appending c_indx as the last variable.
CDNOD_NAME_MAP = {f"X{i + 1}": name for i, name in enumerate(AUGMENTED_VARIABLES)}
REVERSE_CDNOD_NAME_MAP = {name: code for code, name in CDNOD_NAME_MAP.items()}
def parse_causallearn_edge(edge):
"""
Convert a causal-learn edge object into source, endpoint pattern, and target strings.
Parameters
----------
edge : object
Graph edge object or edge row being parsed, classified, or drawn.
Returns
-------
tuple[str, str, str]
Source node, endpoint mark, and target node parsed from a causal-learn edge.
"""
parts = str(edge).strip().split()
if len(parts) != 3:
return {"source": str(edge), "edge_type": "unknown", "target": "unknown"}
return {"source": parts[0], "edge_type": parts[1], "target": parts[2]}
def graph_to_edge_table(graph, label, name_map=None):
"""
Return a tidy edge table from a causal-learn graph object.
Parameters
----------
graph : object
Graph object returned by causal-learn or constructed from the edge table.
label : str
Readable label attached to the method, scenario, or output row.
name_map : object
Mapping from causal-learn internal node labels to notebook variable names.
Returns
-------
pd.DataFrame
Edge table with source, target, edge mark, and method metadata.
"""
rows = []
for edge in graph.get_graph_edges():
parsed = parse_causallearn_edge(edge)
source = name_map.get(parsed["source"], parsed["source"]) if name_map else parsed["source"]
target = name_map.get(parsed["target"], parsed["target"]) if name_map else parsed["target"]
rows.append({"run": label, "source": source, "edge_type": parsed["edge_type"], "target": target})
return pd.DataFrame(rows, columns=["run", "source", "edge_type", "target"])
def run_cdnod_quiet(data, c_index, label, alpha=0.05, background_knowledge=None):
"""
Run CD-NOD while capturing progress text emitted by internal search code.
Parameters
----------
data : array-like or pd.DataFrame
Data passed into the demonstrated method or score function.
c_index : object
Column index of the CD-NOD environment/context variable.
label : str
Readable label attached to the method, scenario, or output row.
alpha : float
Significance level for an independence test or transparency level for plotting.
background_knowledge : object or None
Optional prior constraints supplied to the discovery algorithm.
Returns
-------
tuple
Tuple containing result, pd.DataFrame({'run': label, 'message': messages}), aligned for the causal estimator.
"""
stdout_buffer = io.StringIO()
stderr_buffer = io.StringIO()
with contextlib.redirect_stdout(stdout_buffer), contextlib.redirect_stderr(stderr_buffer):
result = cdnod(
data,
c_indx=c_index,
alpha=alpha,
indep_test="fisherz",
stable=True,
show_progress=False,
background_knowledge=background_knowledge,
)
messages = [line for line in stdout_buffer.getvalue().splitlines() + stderr_buffer.getvalue().splitlines() if line.strip()]
return result, pd.DataFrame({"run": label, "message": messages})
def directed_pairs(edge_df):
"""
Extract definite directed pairs from an edge table.
Parameters
----------
edge_df : pd.DataFrame
Learned or oracle edge table being summarized or drawn.
Returns
-------
set[tuple[str, str]]
Ordered source-target pairs representing directed learned edges.
"""
pairs = set()
for row in edge_df.itertuples(index=False):
if row.edge_type == "-->":
pairs.add((row.source, row.target))
elif row.edge_type == "<--":
pairs.add((row.target, row.source))
return pairs
def skeleton_pairs(edge_df):
"""
Extract adjacencies while ignoring endpoint marks.
Parameters
----------
edge_df : pd.DataFrame
Learned or oracle edge table being summarized or drawn.
Returns
-------
set[tuple[str, str]]
Unordered node pairs representing the learned graph skeleton.
"""
pairs = set()
for row in edge_df.itertuples(index=False):
if row.target != "unknown":
pairs.add(frozenset([row.source, row.target]))
return pairs
def summarize_against_truth(edge_df, truth_df, label):
"""
Compute compact recovery metrics against a truth table.
Parameters
----------
edge_df : pd.DataFrame
Learned or oracle edge table being summarized or drawn.
truth_df : pd.DataFrame
Oracle edge table used as the synthetic ground-truth benchmark.
label : str
Readable label attached to the method, scenario, or output row.
Returns
-------
pd.DataFrame
Graph-recovery summary table with edge counts, precision, recall, missing edges, and extra edges.
"""
true_directed = set(zip(truth_df["source"], truth_df["target"]))
true_skeleton = {frozenset(edge) for edge in true_directed}
learned_directed = directed_pairs(edge_df)
learned_skeleton = skeleton_pairs(edge_df)
correct_directed = learned_directed & true_directed
reversed_true = {(src, dst) for src, dst in true_directed if (dst, src) in learned_directed}
missing_skeleton = true_skeleton - learned_skeleton
extra_skeleton = learned_skeleton - true_skeleton
unresolved_true = 0
for src, dst in true_directed:
pair = frozenset([src, dst])
if pair in learned_skeleton and (src, dst) not in learned_directed and (dst, src) not in learned_directed:
unresolved_true += 1
directed_count = len(learned_directed)
return pd.DataFrame(
[
{
"run": label,
"learned_edges_total": len(edge_df),
"definite_directed_edges": directed_count,
"true_edges": len(true_directed),
"correct_directed_edges": len(correct_directed),
"directed_precision": len(correct_directed) / directed_count if directed_count else np.nan,
"directed_recall": len(correct_directed) / len(true_directed) if true_directed else np.nan,
"reversed_true_edges": len(reversed_true),
"unresolved_true_adjacencies": unresolved_true,
"missing_true_adjacencies": len(missing_skeleton),
"extra_adjacencies": len(extra_skeleton),
}
]
)
def classify_edges(edge_df, truth_df):
"""
Label learned edges relative to the synthetic truth table.
Parameters
----------
edge_df : pd.DataFrame
Learned or oracle edge table being summarized or drawn.
truth_df : pd.DataFrame
Oracle edge table used as the synthetic ground-truth benchmark.
Returns
-------
pd.DataFrame
Graph edge table for classify edges, including node names and edge-orientation information where available.
"""
true_directed = set(zip(truth_df["source"], truth_df["target"]))
true_skeleton = {frozenset(edge) for edge in true_directed}
rows = []
for row in edge_df.itertuples(index=False):
pair = frozenset([row.source, row.target])
learned_direction = None
if row.edge_type == "-->":
learned_direction = (row.source, row.target)
elif row.edge_type == "<--":
learned_direction = (row.target, row.source)
if learned_direction in true_directed:
status = "correct directed edge"
elif learned_direction and (learned_direction[1], learned_direction[0]) in true_directed:
status = "reversed true edge"
elif pair in true_skeleton:
status = "true adjacency with uncertain or wrong endpoint"
else:
status = "extra adjacency"
rows.append({"source": row.source, "edge_type": row.edge_type, "target": row.target, "status": status})
return pd.DataFrame(rows)
def make_cdnod_background_knowledge():
"""
Create simple tier knowledge using CD-NOD's internal X-name convention.
Returns
-------
tuple
Tuple containing knowledge, tiers.
"""
tiers = {
"environment": 0,
"need": 1,
"intent": 1,
"match": 2,
"engagement": 3,
"renewal": 4,
"support": 4,
}
knowledge = BackgroundKnowledge()
nodes = {name: GraphNode(REVERSE_CDNOD_NAME_MAP[name]) for name in tiers}
for name, tier in tiers.items():
knowledge.add_node_to_tier(nodes[name], tier)
knowledge.forbid_within_tier(4)
return knowledge, tiers
GRAPH_POSITIONS = {
"environment": (0.09, 0.50),
"need": (0.28, 0.74),
"intent": (0.28, 0.26),
"match": (0.49, 0.50),
"engagement": (0.72, 0.50),
"renewal": (0.93, 0.74),
"support": (0.93, 0.26),
}
NODE_LABELS = {
"environment": "Environment",
"need": "Need",
"intent": "Intent",
"match": "Match",
"engagement": "Engagement",
"renewal": "Renewal",
"support": "Support",
}
NODE_COLORS = {
"environment": "#f3f4f6",
"need": "#e0f2fe",
"intent": "#dbeafe",
"match": "#ecfccb",
"engagement": "#fef3c7",
"renewal": "#fee2e2",
"support": "#f3e8ff",
}
def trim_edge_to_box(start, end, box_w=0.14, box_h=0.095, gap=0.012):
"""
Return edge endpoints that stop just outside source and target boxes.
Parameters
----------
start : tuple[float, float]
Starting coordinate of the arrow or edge segment.
end : tuple[float, float]
Ending coordinate of the arrow or edge segment.
box_w : float
Width of the node box used to trim edge endpoints.
box_h : float
Height of the node box used to trim edge endpoints.
gap : float
Extra spacing between an edge endpoint and a node boundary.
Returns
-------
tuple[tuple[float, float], tuple[float, float]]
Start and end coordinates trimmed so the edge stops at box boundaries.
"""
x0, y0 = start
x1, y1 = end
dx = x1 - x0
dy = y1 - y0
length = float(np.hypot(dx, dy))
if length == 0:
return start, end
# FancyBboxPatch padding makes the visible box a little larger than box_w/box_h.
effective_w = box_w + 0.04
effective_h = box_h + 0.04
x_limit = (effective_w / 2) / abs(dx) if dx else np.inf
y_limit = (effective_h / 2) / abs(dy) if dy else np.inf
t = min(x_limit, y_limit) + gap / length
return (x0 + dx * t, y0 + dy * t), (x1 - dx * t, y1 - dy * t)
def draw_box_graph(edge_df, title, path, note=None, include_environment=True):
"""
Draw a DAG/CPDAG-style graph with rounded boxes and visible arrowheads.
Parameters
----------
edge_df : pd.DataFrame
Learned or oracle edge table being summarized or drawn.
title : str
Title shown above the plot.
path : str or pathlib.Path
Optional output path for the figure.
note : str or None
Optional explanatory note shown in the plot.
include_environment : bool
Whether the environment/context node is included in the plot.
Returns
-------
None
Draws the graph diagram directly on the Matplotlib axes and saves it when requested.
"""
positions = GRAPH_POSITIONS if include_environment else {k: v for k, v in GRAPH_POSITIONS.items() if k != "environment"}
fig, ax = plt.subplots(figsize=(12, 6.2))
ax.set_axis_off()
ax.set_xlim(-0.03, 1.05)
ax.set_ylim(0.04, 0.96)
box_w, box_h = 0.14, 0.095
for row in edge_df.itertuples(index=False):
if row.source not in positions or row.target not in positions:
continue
raw_start = positions[row.source]
raw_end = positions[row.target]
if row.edge_type == "<--":
raw_start, raw_end = raw_end, raw_start
start, end = trim_edge_to_box(raw_start, raw_end, box_w=box_w, box_h=box_h)
if row.edge_type in {"-->", "<--"}:
arrowstyle = "-|>"
mutation_scale = 18
linewidth = 1.8
color = "#334155"
else:
arrowstyle = "-"
mutation_scale = 1
linewidth = 1.5
color = "#64748b"
arrow = FancyArrowPatch(
start,
end,
arrowstyle=arrowstyle,
mutation_scale=mutation_scale,
linewidth=linewidth,
color=color,
connectionstyle="arc3,rad=0.035",
zorder=2,
)
ax.add_patch(arrow)
for node, (x, y) in positions.items():
rect = FancyBboxPatch(
(x - box_w / 2, y - box_h / 2),
box_w,
box_h,
boxstyle="round,pad=0.018",
facecolor=NODE_COLORS[node],
edgecolor="#1f2937",
linewidth=1.1,
zorder=5,
)
ax.add_patch(rect)
ax.text(x, y, NODE_LABELS[node], ha="center", va="center", fontsize=10.5, fontweight="bold", zorder=6)
if note:
ax.text(0.50, 0.08, note, ha="center", va="center", fontsize=10, color="#475569")
ax.set_title(title, pad=18, fontsize=14, fontweight="bold")
fig.savefig(path, dpi=160, bbox_inches="tight")
plt.show()
def truth_as_edge_table(truth_df, label="truth"):
"""
Convert a truth table into the plotting schema.
Parameters
----------
truth_df : pd.DataFrame
Oracle edge table used as the synthetic ground-truth benchmark.
label : str
Readable label attached to the method, scenario, or output row.
Returns
-------
pd.DataFrame
Oracle graph edge table with run label and directed-edge marks.
"""
return truth_df.assign(run=label, edge_type="-->")[["run", "source", "edge_type", "target"]]