Skip to content

Commit 8f479e3

Browse files
authored
Merge pull request #265 from sanghack81/fix/gst-branch-sort-descending
Visit GST grow branches in descending score order (BOSS/GRaSP)
2 parents dd13787 + 140a858 commit 8f479e3

2 files changed

Lines changed: 73 additions & 1 deletion

File tree

causallearn/search/PermutationBased/gst.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,10 @@ def grow(self, available, parents):
2222
parents.remove(add)
2323
branch = GSTNode(self.tree, add, score)
2424
if score > self.grow_score: self.branches.append(branch)
25-
self.branches.sort()
25+
# Visit higher-scoring grow branches first, matching TETRAD's
26+
# GrowShrinkTree: GSTNode.compareTo() is ascending in growScore, but
27+
# grow() sorts with Collections.reverseOrder(). See py-why/causal-learn#264.
28+
self.branches.sort(reverse=True)
2629

2730
def shrink(self, parents):
2831
self.remove = []

tests/TestGST.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import unittest
2+
3+
import numpy as np
4+
5+
from causallearn.search.PermutationBased.gst import GST
6+
7+
8+
# Deterministic local scores for vertex 0 over candidate parents {1, 2, 3},
9+
# taken from py-why/causal-learn#264. BOSS uses a higher-is-better score.
10+
# S({1}) and S({2}) use the issue's reported trace values; the remaining
11+
# entries use the issue's local-score table.
12+
_LOCAL_SCORES = {
13+
frozenset(): 0.0,
14+
frozenset({1}): 2.0837,
15+
frozenset({2}): 4.3568,
16+
frozenset({3}): 1.53,
17+
frozenset({1, 2}): -0.03,
18+
frozenset({1, 3}): 3.29,
19+
frozenset({2, 3}): 2.33,
20+
frozenset({1, 2, 3}): -0.25,
21+
}
22+
23+
24+
class _MockScore:
25+
"""Minimal score object exposing only the interface that GST relies on."""
26+
27+
def __init__(self, n_vars, scores):
28+
# GST reads data.shape[1] to enumerate the candidate parents.
29+
self.data = np.zeros((1, n_vars))
30+
self._scores = scores
31+
32+
def score(self, i, PAi):
33+
return self._scores[frozenset(PAi)]
34+
35+
def score_nocache(self, i, PAi):
36+
return self._scores[frozenset(PAi)]
37+
38+
39+
class TestGST(unittest.TestCase):
40+
"""Regression test for GST grow-branch ordering (py-why/causal-learn#264).
41+
42+
BOSS uses a higher-is-better score, and TETRAD's GrowShrinkTree visits grow
43+
branches in descending growScore order. The trace path -- not just the
44+
presentation order -- depends on this direction, so the highest-scoring grow
45+
branch reachable within the prefix must be visited first.
46+
"""
47+
48+
def test_grow_branches_sorted_descending(self):
49+
gst = GST(0, _MockScore(4, _LOCAL_SCORES))
50+
# Force the root to grow over all candidates {1, 2, 3}.
51+
gst.trace([1, 2, 3])
52+
grow_scores = [branch.grow_score for branch in gst.root.branches]
53+
self.assertEqual(grow_scores, sorted(grow_scores, reverse=True))
54+
# 2 (4.3568) > 1 (2.0837) > 3 (1.53)
55+
self.assertEqual([branch.add for branch in gst.root.branches], [2, 1, 3])
56+
57+
def test_trace_follows_highest_scoring_branch(self):
58+
# With prefix [1, 2], both {1} and {2} are reachable grow branches.
59+
# Descending order (TETRAD-style) visits {2} first -> parents {2}.
60+
# Ascending order (the pre-fix behavior) would visit {1} -> parents {1}.
61+
gst = GST(0, _MockScore(4, _LOCAL_SCORES))
62+
parents = []
63+
score = gst.trace([1, 2], parents)
64+
self.assertEqual(parents, [2])
65+
self.assertAlmostEqual(score, 4.3568, places=6)
66+
67+
68+
if __name__ == "__main__":
69+
unittest.main()

0 commit comments

Comments
 (0)