diff --git a/src/piqtree/iqtree/_model_finder.py b/src/piqtree/iqtree/_model_finder.py index e46c9c06..a1879830 100644 --- a/src/piqtree/iqtree/_model_finder.py +++ b/src/piqtree/iqtree/_model_finder.py @@ -11,6 +11,7 @@ from piqtree.iqtree._decorator import iqtree_func from piqtree.model import Model, make_model +from piqtree.util import process_rand_seed_nonzero iq_model_finder = iqtree_func(iq_model_finder, hide_files=True) @@ -140,7 +141,7 @@ def model_finder( Search space for rate heterogeneity types. Equivalent to IQ-TREE's mrate parameter, by default None rand_seed : int | None, optional - The random seed - 0 or None means no seed, by default None. + The random seed - None means no seed is used, by default None. num_threads: int | None, optional Number of threads for IQ-TREE to use, by default None (single-threaded). If 0 is specified, IQ-TREE attempts to find the optimal number of threads. @@ -152,8 +153,8 @@ def model_finder( """ source = cast("str", aln.info.source) - if rand_seed is None: - rand_seed = 0 # The default rand_seed in IQ-TREE + + rand_seed = process_rand_seed_nonzero(rand_seed) if num_threads is None: num_threads = 1 diff --git a/src/piqtree/iqtree/_random_tree.py b/src/piqtree/iqtree/_random_tree.py index 6c57f677..f41716ed 100644 --- a/src/piqtree/iqtree/_random_tree.py +++ b/src/piqtree/iqtree/_random_tree.py @@ -7,6 +7,7 @@ from cogent3.core.tree import PhyloNode from piqtree.iqtree._decorator import iqtree_func +from piqtree.util import process_rand_seed_nonzero iq_random_tree = iqtree_func(iq_random_tree) @@ -38,7 +39,7 @@ def random_tree( tree_mode : TreeGenMode How the tree is generated. rand_seed : int | None, optional - The random seed - 0 or None means no seed, by default None. + The random seed - None means no seed is used, by default None. Returns ------- @@ -46,8 +47,7 @@ def random_tree( A random phylogenetic tree. """ - if rand_seed is None: - rand_seed = 0 # The default rand_seed in IQ-TREE + rand_seed = process_rand_seed_nonzero(rand_seed) newick = iq_random_tree(num_taxa, tree_mode.name, 1, rand_seed).strip() return make_tree(newick) diff --git a/src/piqtree/iqtree/_tree.py b/src/piqtree/iqtree/_tree.py index eaa0899a..42c468e9 100644 --- a/src/piqtree/iqtree/_tree.py +++ b/src/piqtree/iqtree/_tree.py @@ -15,7 +15,7 @@ from piqtree.iqtree._decorator import iqtree_func from piqtree.iqtree._parse_tree_parameters import parse_model_parameters from piqtree.model import Model, make_model -from piqtree.util import get_newick +from piqtree.util import get_newick, process_rand_seed_nonzero iq_build_tree = iqtree_func(iq_build_tree, hide_files=True) iq_fit_tree = iqtree_func(iq_fit_tree, hide_files=True) @@ -99,7 +99,7 @@ def build_tree( model : Model | str The substitution model with base frequencies and rate heterogeneity. rand_seed : int | None, optional - The random seed - 0 or None means no seed, by default None. + The random seed - None means no seed is used, by default None. bootstrap_replicates : int, optional The number of bootstrap replicates to perform, by default None. If 0 is provided, then no bootstrapping is performed. @@ -117,8 +117,7 @@ def build_tree( if isinstance(model, str): model = make_model(model) - if rand_seed is None: - rand_seed = 0 # The default rand_seed in IQ-TREE + rand_seed = process_rand_seed_nonzero(rand_seed) if bootstrap_replicates is None: bootstrap_replicates = 0 diff --git a/src/piqtree/util/__init__.py b/src/piqtree/util/__init__.py index 322da682..9b607ea1 100644 --- a/src/piqtree/util/__init__.py +++ b/src/piqtree/util/__init__.py @@ -1,5 +1,65 @@ +import secrets + from cogent3.core.tree import PhyloNode def get_newick(tree: PhyloNode) -> str: return tree.get_newick(with_distances=True, escape_name=False) + + +def make_rand_seed() -> int: + """Make a 32-bit random seed. + + Returns + ------- + int + A 32-bit random seed. + """ + seed = secrets.randbits(32) + return seed if seed < (1 << 31) else seed - (1 << 32) + + +def make_nonzero_rand_seed() -> int: + """Make a non-zero 32-bit random seed. + + Returns + ------- + int + A non-zero 32-bit random seed. + """ + while (seed := make_rand_seed()) == 0: + pass # pragma: nocov + return seed + + +def process_rand_seed_nonzero(seed: int | None) -> int: + """Process a random seed before sending to IQ-TREE. + + Some functions treat 0 as non-deterministic. For + consistency between methods, 0 is treated as always + deterministic and replaced with a pre-determined + number. + + If the seed is None, a random seed is generated. + If the seed is 0, it is replaced with a pre-determined + number (1158426093). + If the seed is anything else, returns that number. + + Parameters + ---------- + seed : int | None + The random seed to process. + + Returns + ------- + int + The original seed if no processing is required. + Otherwise a random non-zero seed is the original seed + was None. If the original seed was zero, a pre-determined + number is returned. + """ + if seed == 0: + seed = 1074213633 # Randomly chosen once with make_rand_seed + if seed is None: + seed = make_nonzero_rand_seed() + return seed diff --git a/tests/test_iqtree/test_random_tree.py b/tests/test_iqtree/test_random_tree.py new file mode 100644 index 00000000..1425d98f --- /dev/null +++ b/tests/test_iqtree/test_random_tree.py @@ -0,0 +1,39 @@ +import pytest + +from piqtree import TreeGenMode, random_tree +from piqtree.exceptions import IqTreeError + + +@pytest.mark.parametrize("num_taxa", [10, 50, 100]) +@pytest.mark.parametrize("tree_mode", list(TreeGenMode)) +def test_random_tree(num_taxa: int, tree_mode: TreeGenMode) -> None: + tree = random_tree(num_taxa, tree_mode, rand_seed=1) + assert len(tree.tips()) == num_taxa + + +@pytest.mark.parametrize("rand_seed", [0, 1234]) +def test_random_tree_determinism(rand_seed: int) -> None: + tree_1 = random_tree(10, TreeGenMode.YULE_HARDING, rand_seed=rand_seed) + tree_2 = random_tree(10, TreeGenMode.YULE_HARDING, rand_seed=rand_seed) + + for node_a, node_b in zip(tree_1.postorder(), tree_2.postorder(), strict=True): + assert node_a.name == node_b.name + assert node_a.length == node_b.length + assert len(node_a.children) == len(node_b.children) + + +@pytest.mark.parametrize("num_taxa", [10, 50, 100]) +@pytest.mark.parametrize("tree_mode", list(TreeGenMode)) +def test_random_tree_no_seed(num_taxa: int, tree_mode: TreeGenMode) -> None: + tree = random_tree(num_taxa, tree_mode) + assert len(tree.tips()) == num_taxa + + +@pytest.mark.parametrize("num_taxa", [-1, 0, 1, 2]) +@pytest.mark.parametrize("tree_mode", list(TreeGenMode)) +def test_invalid_taxa( + num_taxa: int, + tree_mode: TreeGenMode, +) -> None: + with pytest.raises(IqTreeError): + _ = random_tree(num_taxa, tree_mode, rand_seed=1) diff --git a/tests/test_iqtree/test_random_trees.py b/tests/test_iqtree/test_random_trees.py deleted file mode 100644 index 4a9d1047..00000000 --- a/tests/test_iqtree/test_random_trees.py +++ /dev/null @@ -1,28 +0,0 @@ -import pytest - -import piqtree -import piqtree.exceptions - - -@pytest.mark.parametrize("num_taxa", [10, 50, 100]) -@pytest.mark.parametrize("tree_mode", list(piqtree.TreeGenMode)) -def test_random_tree(num_taxa: int, tree_mode: piqtree.TreeGenMode) -> None: - tree = piqtree.random_tree(num_taxa, tree_mode, rand_seed=1) - assert len(tree.tips()) == num_taxa - - -@pytest.mark.parametrize("num_taxa", [10, 50, 100]) -@pytest.mark.parametrize("tree_mode", list(piqtree.TreeGenMode)) -def test_random_tree_no_seed(num_taxa: int, tree_mode: piqtree.TreeGenMode) -> None: - tree = piqtree.random_tree(num_taxa, tree_mode) - assert len(tree.tips()) == num_taxa - - -@pytest.mark.parametrize("num_taxa", [-1, 0, 1, 2]) -@pytest.mark.parametrize("tree_mode", list(piqtree.TreeGenMode)) -def test_invalid_taxa( - num_taxa: int, - tree_mode: piqtree.TreeGenMode, -) -> None: - with pytest.raises(piqtree.exceptions.IqTreeError): - _ = piqtree.random_tree(num_taxa, tree_mode, rand_seed=1)