• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

rmcar17 / cogent3 / 16431585733

16 Jul 2025 07:02AM UTC coverage: 90.819% (+0.004%) from 90.815%
16431585733

push

github

web-flow
Merge pull request #2403 from GavinHuttley/develop

DEV: bump version to 2025.7.10a3

1 of 1 new or added line in 1 file covered. (100.0%)

498 existing lines in 32 files now uncovered.

30123 of 33168 relevant lines covered (90.82%)

5.45 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

96.05
/src/cogent3/evolve/likelihood_function.py
1
import json
6✔
2
import random
6✔
3
from collections import defaultdict
6✔
4
from copy import deepcopy
6✔
5

6
import numpy
6✔
7

8
import cogent3
6✔
9
from cogent3._version import __version__
6✔
10
from cogent3.core import alignment as c3_alignment
6✔
11
from cogent3.core import table
6✔
12
from cogent3.core.tree import PhyloNode
6✔
13
from cogent3.evolve import substitution_model
6✔
14
from cogent3.evolve.simulate import AlignmentEvolver, random_sequence
6✔
15
from cogent3.maths.matrix_exponential_integration import expected_number_subs
6✔
16
from cogent3.maths.matrix_logarithm import is_generator_unique
6✔
17
from cogent3.maths.measure import (
6✔
18
    paralinear_continuous_time,
19
    paralinear_discrete_time,
20
)
21
from cogent3.recalculation.definition import ParameterController
6✔
22
from cogent3.recalculation.scope import InvalidScopeError
6✔
23
from cogent3.util.dict_array import DictArrayTemplate
6✔
24
from cogent3.util.misc import adjusted_gt_minprob, get_object_provenance
6✔
25

26

27
def _format_floats(val) -> str:
6✔
28
    """Format float values to two decimal places."""
29
    return f"{val:.2f}" if isinstance(val, float) else val
6✔
30

31

32
def _update_stat_table_col_formatting(table):
6✔
33
    """handle formatting mixed type stat result columns."""
34
    for col in table.columns:
6✔
35
        type_name = table.columns[col].dtype.name
6✔
36
        if type_name == "object" or type_name.startswith("float"):
6✔
37
            table.format_column(col, _format_floats)
6✔
38

39
    return table
6✔
40

41

42
# cogent3.evolve.parameter_controller.LikelihoodParameterController tells the
43
# recalculation framework to use this subclass rather than the generic
44
# recalculation Calculator.  It adds methods which are useful for examining
45
# the parameter, psub, mprob and likelihood values after the optimisation is
46
# complete.
47

48
AlignType = c3_alignment.Alignment
6✔
49

50

51
def _get_keyed_rule_indices(rules):
6✔
52
    """returns {frozesent((par_name, edge1, edge2, ..)): index}"""
53
    new = {}
6✔
54
    for i, rule in enumerate(rules):
6✔
55
        edges = rule.get("edges", rule.get("edge", None)) or []
6✔
56
        edges = [edges] if type(edges) == str else edges
6✔
57
        par_name = rule["par_name"]
6✔
58
        key = frozenset([par_name, *edges])
6✔
59
        new[key] = i
6✔
60
    return new
6✔
61

62

63
def update_rule_value(rich, null):
6✔
64
    """applies value from null rule to rich rule"""
65
    val_key = "init" if "init" in rich else "value"
6✔
66
    rich[val_key] = null.get("init", null.get("value"))
6✔
67
    return rich
6✔
68

69

70
def extend_rule_value(rich, nulls):
6✔
71
    """creates new rich rules from edges in null rules"""
72
    val_key = "init" if "init" in rich else "value"
6✔
73
    rules = []
6✔
74
    for null in nulls:
6✔
75
        edges = null.get("edges", null.get("edge"))
6✔
76
        edges = [edges] if type(edges) == str else edges
6✔
77
        for edge in edges:
6✔
78
            rule = deepcopy(rich)
6✔
79
            rule["edge"] = edge
6✔
80
            rule[val_key] = null.get("init", null.get("value"))
6✔
81
            rules.append(rule)
6✔
82
    return rules
6✔
83

84

85
def update_scoped_rules(rich, null):
6✔
86
    """returns rich rules with values derived from those in null rules"""
87
    new_rules = []
6✔
88
    rich = deepcopy(rich)
6✔
89
    # we build a dict keyed by frozen set consisting of the param name
90
    # and affected edges. The dict value is the list index in original.
91
    richd = _get_keyed_rule_indices(rich)
6✔
92
    nulld = _get_keyed_rule_indices(null)
6✔
93
    common = set(richd) & set(nulld)
6✔
94
    # 1-to-1 mapping, just extract the param value
95
    for key in common:
6✔
96
        rule = update_rule_value(rich[richd[key]], null[nulld[key]])
6✔
97
        new_rules.append(rule)
6✔
98

99
    # following rules differing in scope
100
    rich_remainder = set(richd) - set(nulld)
6✔
101
    null_remainder = set(nulld) - set(richd)
6✔
102
    for rich_key in rich_remainder:
6✔
103
        matches = []
6✔
104
        rich_rule = rich[richd[rich_key]]
6✔
105
        pname = rich_rule["par_name"]
6✔
106
        enames = rich_rule.get("edges", rich_rule.get("edge", None))
6✔
107
        if type(enames) == str:
6✔
108
            enames = [enames]
6✔
109
        enames = None if enames is None else set(enames)
6✔
110
        for null_key in null_remainder:
6✔
111
            null_rule = null[nulld[null_key]]
6✔
112
            if pname != null_rule["par_name"]:
6✔
113
                continue
6✔
114
            # parameter now fully general
115
            if enames is None:
6✔
116
                matches.append(null_rule)
6✔
117
                continue
6✔
118

119
            null_enames = null_rule.get("edges", null_rule.get("edge", None))
6✔
120
            null_enames = None if null_enames is None else set(null_enames)
6✔
121
            if None in (enames, null_enames) or null_enames & enames:
6✔
122
                matches.append(null_rule)
6✔
123

124
        if enames is None:  # rich rule is "free"
6✔
125
            new_rules.extend(extend_rule_value(rich_rule, matches))
6✔
126
            continue
6✔
127

128
        if len(matches) > 1 and enames is not None:
6✔
129
            msg = f"{rich_key} has too many mappings {matches}"
×
130
            raise ValueError(msg)
×
131

132
        match = matches[0]
6✔
133
        new_rules.append(update_rule_value(rich_rule, match))
6✔
134
    return new_rules
6✔
135

136

137
def _get_param_mapping(rich, simple):
6✔
138
    """returns {simple_param_name: rich_param_name, ...}, the mapping of simple
139
    to rich parameters based on matrix coordinates
140
    """
141
    assert len(rich) >= len(simple)
6✔
142
    simple_to_rich = defaultdict(set)
6✔
143
    rich_to_simple = defaultdict(set)
6✔
144
    for simple_param in simple:
6✔
145
        simple_coords = simple[simple_param]
6✔
146
        for rich_param in rich:
6✔
147
            rich_coords = rich[rich_param]
6✔
148
            if rich_coords <= simple_coords:
6✔
149
                simple_to_rich[simple_param].add(rich_param)
6✔
150
                rich_to_simple[rich_param].add(simple_param)
6✔
151

152
    for rich_param, simple_counterparts in rich_to_simple.items():
6✔
153
        if len(simple_counterparts) == 1:
6✔
154
            continue
6✔
155

156
        sized_simple = [(len(simple[param]), param) for param in simple_counterparts]
6✔
157
        sized_simple.sort()
6✔
158
        if sized_simple[0][0] == sized_simple[1][0]:
6✔
159
            msg = f"{sized_simple[0][1]} and {sized_simple[1][1]} tied for matrix space"
×
160
            raise ValueError(msg)
×
161

162
        _, chosen = sized_simple.pop(0)
6✔
163
        rich_to_simple[rich_param] = [chosen]
6✔
164
        for _, simple_param in sized_simple:
6✔
165
            simple_to_rich[simple_param].remove(rich_param)
6✔
166

167
    return simple_to_rich
6✔
168

169

170
class _ParamProjection:
6✔
171
    """projects parameter names, values between nested models"""
172

173
    def __init__(self, simple_model, rich_model, motif_probs, same=True) -> None:
6✔
174
        # construct following by calling the functions we wrote
175
        self._rich_coords = rich_model.get_param_matrix_coords(include_ref_cell=True)
6✔
176
        self._simple_coords = simple_model.get_param_matrix_coords(
6✔
177
            include_ref_cell=True,
178
        )
179
        self._param_map = _get_param_mapping(self._rich_coords, self._simple_coords)
6✔
180
        self._same = same
6✔
181
        # end of constructing attributes
182
        self._motif_probs = motif_probs
6✔
183
        self._ref_val = self._set_ref_val(same)
6✔
184
        self.projected_rate = {False: self._rate_not_same}.get(same, self._rate_same)
6✔
185

186
    def _set_ref_val(self, same):
6✔
187
        """returns the motif prob corresponding to the model reference cell"""
188
        if same:
6✔
189
            return 1
6✔
190
        i, j = next(iter(self._rich_coords["ref_cell"]))
6✔
191
        return self._motif_probs[j]
6✔
192

193
    def _rate_not_same(self, simple_param, mle):
6✔
194
        """returns {rich_param: val, ...} from simple_param: val"""
195
        ref_val = self._ref_val
6✔
196
        new_terms = {}
6✔
197
        for rich_param in self._param_map[simple_param]:
6✔
198
            if rich_param == "ref_cell":
6✔
199
                continue
6✔
200
            for _i, j in self._rich_coords[rich_param]:
6✔
201
                new_terms[rich_param] = self._motif_probs[j] * mle / ref_val
6✔
202

203
        return new_terms
6✔
204

205
    def _rate_same(self, simple_param, mle):
6✔
206
        new_terms = {}
6✔
207
        for rich_param in self._param_map[simple_param]:
6✔
208
            if rich_param == "ref_cell":
6✔
209
                continue
×
210
            for _i, _j in self._rich_coords[rich_param]:
6✔
211
                new_terms[rich_param] = mle
6✔
212
        return new_terms
6✔
213

214
    def update_param_rules(self, rules):
6✔
215
        new_rules = []
6✔
216
        if not self._same:
6✔
217
            rules = rules[:] + [{"par_name": "ref_cell", "init": 1.0, "edges": None}]
6✔
218

219
        for rule in rules:
6✔
220
            # get the param name, mle, call self.projected_rate
221
            name = rule["par_name"]
6✔
222
            if name in ("mprobs", "length"):
6✔
223
                new_rules.append(rule)
6✔
224
                continue
6✔
225

226
            par_val_key = "value" if rule.get("is_constant", False) else "init"
6✔
227

228
            mle = rule[par_val_key]
6✔
229

230
            proj_rate = self.projected_rate(name, mle)
6✔
231
            for new_name, new_mle in proj_rate.items():
6✔
232
                rule_dict = rule.copy()
6✔
233
                rule_dict["par_name"] = new_name
6✔
234
                # update it with the new parname and mle and append to new rules
235
                rule_dict["init"] = new_mle
6✔
236
                new_rules.append(rule_dict)
6✔
237

238
        return new_rules
6✔
239

240

241
def compatible_likelihood_functions(lf1, lf2) -> bool:
6✔
242
    """returns True if all attributes of the two likelihood functions are compatible
243
    for mapping parameters, else raises ValueError or an AssertionError"""
244
    # tree's must have the same topology AND be oriented the same way
245
    # plus have the same edge names
246
    if len(lf1.bin_names) != 1 or len(lf1.bin_names) != len(lf2.bin_names):
6✔
247
        msg = "Too many bins"
×
248
        raise NotImplementedError(msg)
×
249
    if len(lf1.locus_names) != 1 or len(lf1.locus_names) != len(lf2.locus_names):
6✔
250
        msg = "Too many loci"
×
251
        raise NotImplementedError(msg)
×
252
    if lf1.model.get_motifs() != lf2.model.get_motifs():
6✔
253
        msg = "Motifs don't match"
×
254
        raise AssertionError(msg)
×
255
    if lf1.tree.get_newick(with_node_names=True) != lf2.tree.get_newick(
6✔
256
        with_node_names=True,
257
    ):
258
        msg = "Topology, Orientation or node names don't match"
×
259
        raise AssertionError(msg)
×
260
    return True
6✔
261

262

263
class LikelihoodFunction(ParameterController):
6✔
264
    @property
6✔
265
    def lnL(self):
6✔
266
        """log-likelihood"""
267
        return self.get_log_likelihood()
6✔
268

269
    def get_log_likelihood(self) -> float:
6✔
270
        return self.get_final_result()
6✔
271

272
    def get_all_psubs(self) -> dict:
6✔
273
        """returns all psubs as a dict keyed by used dimensions"""
274
        try:
6✔
275
            defn = self.defn_for["dsubs"]
6✔
276
        except KeyError:
6✔
277
            defn = self.defn_for["psubs"]
6✔
278

279
        edge_names = {e.name for e in self.tree.postorder(include_self=False)}
6✔
280
        used_dims = defn.used_dimensions()
6✔
281
        vdims = defn.valid_dimensions
6✔
282
        indices = [vdims.index(k) for k in used_dims if k in vdims]
6✔
283
        result = {}
6✔
284
        key_len = 1
6✔
285
        darr_template = DictArrayTemplate(self._motifs, self._motifs)
6✔
286
        for scope, index in defn.index.items():
6✔
287
            psub = defn.values[index]
6✔
288
            key = tuple(v.item() for v in numpy.take(scope, indices))
6✔
289
            key_len = len(key)
6✔
290
            edge_names -= set(key)
6✔
291
            key = key[0] if key_len == 1 else key
6✔
292
            result[key] = darr_template.wrap(psub)
6✔
293

294
        if edge_names:
6✔
295
            # if there are edges not in the psubs, they're probably
296
            # edges with discrete-time processes
297
            for edge_name in edge_names:
6✔
298
                key = edge_name if key_len == 1 else (edge_name,)
6✔
299
                result[key] = self.get_psub_for_edge(edge_name)
6✔
300
        return result
6✔
301

302
    def get_psub_for_edge(self, name, **kw):
6✔
303
        """returns the substitution probability matrix for the named edge
304

305
        Parameters
306
        ----------
307
        name : str
308
            name of the edge
309

310
        Returns
311
        -------
312
        DictArray
313
        """
314
        # TODO handle case of multiple loci
315
        try:
6✔
316
            # For PartialyDiscretePsubsDefn
317
            array = self.get_param_value("dpsubs", edge=name, **kw)
6✔
318
        except KeyError:
6✔
319
            array = self.get_param_value("psubs", edge=name, **kw)
6✔
320
        return DictArrayTemplate(self._motifs, self._motifs).wrap(array)
6✔
321

322
    def get_all_rate_matrices(self, calibrated=True):
6✔
323
        """returns all rate matrices (Q) as a dict, keyed by scope
324

325
        Parameters
326
        ----------
327
        calibrated : bool
328
            If True, the rate matrix is scaled such that
329
            ``sum(pi_i * Qii) == 1``. If False, the calibrated matrix is
330
            multiplied by the length parameter (and the rate parameter for a
331
            bin if it is a rate heterogeneity model).
332

333
        Returns
334
        -------
335
        {scope: DictArray, ...}
336

337
        Notes
338
        -----
339
        If a single rate matrix (e.g. it's a time-homogeneous model), the key
340
        is an empty tuple.
341
        """
342
        defn = self.defn_for["Q"]
6✔
343

344
        rate_het = self.defn_for.get("rate", False)
6✔
345
        if rate_het:
6✔
346
            bin_index = rate_het.valid_dimensions.index("bin")
6✔
347
            bin_names = [k[bin_index] for k in rate_het.index]
6✔
348
            bin_names = {n: i for i, n in enumerate(bin_names)}
6✔
349
            bin_index = defn.valid_dimensions.index("bin")
6✔
350
        else:
351
            bin_names = None
6✔
352
            bin_index = None
6✔
353

354
        used_dims = defn.used_dimensions()
6✔
355
        edge_index = defn.valid_dimensions.index("edge")
6✔
356

357
        indices = {defn.valid_dimensions.index(k) for k in used_dims}
6✔
358
        if not calibrated:
6✔
359
            indices.add(edge_index)
6✔
360

361
        if not calibrated and rate_het:
6✔
362
            indices.add(bin_index)
6✔
363

364
        indices = sorted(indices)
6✔
365
        result = {}
6✔
366
        darr_template = DictArrayTemplate(self._motifs, self._motifs)
6✔
367
        for scope, index in defn.index.items():
6✔
368
            q = defn.values[index]  # this gives the appropriate Q
6✔
369
            # from scope we extract only the relevant dimensions
370
            key = tuple(numpy.take(scope, indices))
6✔
371
            q = q.copy()
6✔
372
            if not calibrated:
6✔
373
                length = self.get_param_value("length", edge=scope[edge_index])
6✔
374
                if rate_het:
6✔
375
                    bdex = bin_names[scope[bin_index]]
6✔
376
                    rate = rate_het.values[bdex]
6✔
377
                    length *= rate
6✔
378
                q *= length
6✔
379
            result[key] = darr_template.wrap(q)
6✔
380
            if not indices and calibrated:
6✔
381
                break  # single rate matrix
×
382

383
        return result
6✔
384

385
    def get_rate_matrix_for_edge(self, name, calibrated=True, **kw):
6✔
386
        """returns the rate matrix (Q) for the named edge
387

388
        Parameters
389
        ----------
390
        name : str
391
            name of the edge
392
        calibrated : bool
393
            If True, the rate matrix is scaled such that
394
            ``sum(pi_i * Qii) == 1``. If False, the calibrated matrix is
395
            multiplied by the length parameter (and the rate parameter for a
396
            bin if it is a rate heterogeneity model).
397

398
        Notes
399
        -----
400
        If ``calibrated=False``, ``expm(Q)`` will give the same result as
401
        ``self.get_psub_for_edge(name)``
402
        """
403
        # TODO handle case of multiple loci
404
        try:
6✔
405
            array = self.get_param_value("Q", edge=name, **kw)
6✔
406
            array = array.copy()
6✔
407
            if not calibrated:
6✔
408
                length = self.get_param_value("length", edge=name, **kw)
6✔
409
                array *= length
6✔
410
        except KeyError as err:
6✔
411
            raise InvalidScopeError from err
6✔
412

413
        return DictArrayTemplate(self._motifs, self._motifs).wrap(array)
6✔
414

415
    def _getLikelihoodValuesSummedAcrossAnyBins(self, locus=None):
6✔
416
        if self.bin_names and len(self.bin_names) > 1:
6✔
417
            root_lhs = [
6✔
418
                self.get_param_value("lh", locus=locus, bin=bin)
419
                for bin in self.bin_names
420
            ]
421
            bprobs = self.get_param_value("bprobs")
6✔
422
            root_lh = bprobs.dot(root_lhs)
6✔
423
        else:
424
            root_lh = self.get_param_value("lh", locus=locus)
6✔
425
        return root_lh
6✔
426

427
    def get_full_length_likelihoods(self, locus=None):
6✔
428
        """Array of [site, motif] likelihoods from the root of the tree"""
429
        root_lh = self._getLikelihoodValuesSummedAcrossAnyBins(locus=locus)
6✔
430
        root_lht = self.get_param_value("root", locus=locus)
6✔
431
        return root_lht.get_full_length_likelihoods(root_lh)
6✔
432

433
    def get_G_statistic(self, return_table=False, locus=None):
6✔
434
        """Goodness-of-fit statistic derived from the unambiguous columns"""
435
        root_lh = self._getLikelihoodValuesSummedAcrossAnyBins(locus=locus)
6✔
436
        root_lht = self.get_param_value("root", locus=locus)
6✔
437
        return root_lht.calc_G_statistic(root_lh, return_table)
6✔
438

439
    def reconstruct_ancestral_seqs(self, locus=None):
6✔
440
        """computes the conditional probabilities of each state for each node
441
        in the tree.
442

443
        Parameters
444
        ----------
445
        locus
446
            a named locus
447

448
        Returns
449
        -------
450
        {node_name: DictArray, ...}
451

452
        Notes
453
        -----
454
        Alignment columns are rows in the DictArray.
455
        """
456
        result = {}
6✔
457
        array_template = None
6✔
458
        for restricted_edge in self._tree.get_edge_vector():
6✔
459
            if restricted_edge.istip():
6✔
460
                continue
6✔
461
            try:
6✔
462
                r = []
6✔
463
                for motif in range(len(self._motifs)):
6✔
464
                    self.set_param_rule(
6✔
465
                        "fixed_motif",
466
                        value=motif,
467
                        edge=restricted_edge.name,
468
                        locus=locus,
469
                        is_constant=True,
470
                    )
471
                    likelihoods = self.get_full_length_likelihoods(locus=locus)
6✔
472
                    r.append(likelihoods)
6✔
473
                    if array_template is None:
6✔
474
                        array_template = DictArrayTemplate(
6✔
475
                            likelihoods.shape[0],
476
                            self._motifs,
477
                        )
478
            finally:
479
                self.set_param_rule(
6✔
480
                    "fixed_motif",
481
                    value=-1,
482
                    edge=restricted_edge.name,
483
                    locus=locus,
484
                    is_constant=True,
485
                )
486
            # dict of site x motif arrays
487
            result[restricted_edge.name] = array_template.wrap(
6✔
488
                numpy.transpose(numpy.asarray(r)),
489
            )
490
        return result
6✔
491

492
    def likely_ancestral_seqs(self, locus=None) -> AlignType:
6✔
493
        """Returns the most likely reconstructed ancestral sequences as an
494
        alignment.
495

496
        Parameters
497
        ----------
498
        locus
499
            a named locus
500
        """
501
        prob_array = self.reconstruct_ancestral_seqs(locus=locus)
6✔
502
        seqs = []
6✔
503
        for edge, probs in list(prob_array.items()):
6✔
504
            seq = []
6✔
505
            for row in probs:
6✔
506
                by_p = [(p, state) for state, p in list(row.items())]
6✔
507
                seq.append(max(by_p)[1])
6✔
508
            seqs += [(edge, self.model.moltype.make_seq(seq="".join(seq)))]
6✔
509
        return cogent3.make_aligned_seqs(
6✔
510
            seqs,
511
            moltype=self.model.moltype,
512
        )
513

514
    def get_bin_probs(self, locus=None):
6✔
515
        hmm = self.get_param_value("bindex", locus=locus)
6✔
516
        lhs = [
6✔
517
            self.get_param_value("lh", locus=locus, bin=bin) for bin in self.bin_names
518
        ]
519
        array = hmm.get_posterior_probs(*lhs)
6✔
520
        return DictArrayTemplate(self.bin_names, array.shape[1]).wrap(array)
6✔
521

522
    def _valuesForDimension(self, dim):
6✔
523
        # in support of __str__
524
        if dim == "edge":
6✔
525
            result = [e.name for e in self._tree.get_edge_vector()]
6✔
526
        elif dim == "bin":
6✔
UNCOV
527
            result = self.bin_names[:]
×
528
        elif dim == "locus":
6✔
529
            result = self.locus_names[:]
6✔
530
        elif dim.startswith("motif"):
6✔
531
            result = self._mprob_motifs
6✔
532
        elif dim == "position":
6✔
533
            result = self.posn_names[:]
6✔
534
        else:
UNCOV
535
            raise KeyError(dim)
×
536
        return result
6✔
537

538
    def _valuesForDimensions(self, dims):
6✔
539
        # in support of __str__
540
        result = [[]]
6✔
541
        for dim in dims:
6✔
542
            new_result = []
6✔
543
            for r in result:
6✔
544
                for cat in self._valuesForDimension(dim):
6✔
545
                    new_result.append([*r, cat])
6✔
546
            result = new_result
6✔
547
        return result
6✔
548

549
    def _for_display(self):
6✔
550
        """processes statistics tables for display"""
551
        title = self.name or "Likelihood function statistics"
6✔
552
        result = []
6✔
553
        result += self.get_statistics(with_motif_probs=True, with_titles=True)
6✔
554
        for i, table_ in enumerate(result):
6✔
555
            if (
6✔
556
                "motif" in table_.title
557
                and table_.shape[1] == 2
558
                and table_.shape[0] >= 60
559
            ):  # just sort codon motif probs, then truncate
UNCOV
560
                table_ = table_.sorted(columns="motif")
×
561
                table_.set_repr_policy(head=5, tail=5, show_shape=False)
×
562
                result[i] = table_
×
563
        return title, result
6✔
564

565
    def _repr_html_(self):
6✔
566
        """for jupyter notebook display"""
567
        try:
6✔
568
            lnL = f"<p>log-likelihood = {self.get_log_likelihood():.4f}</p>"
6✔
UNCOV
569
        except ValueError:
×
570
            # alignment probably not yet set
UNCOV
571
            lnL = ""
×
572

573
        nfp = "<p>number of free parameters = %d</p>" % self.get_num_free_params()
6✔
574
        title, results = self._for_display()
6✔
575
        for i, table_ in enumerate(results):
6✔
576
            table_.title = table_.title.capitalize()
6✔
577
            table_.set_repr_policy(show_shape=False)
6✔
578
            results[i] = table_._repr_html_()
6✔
579
        results = [f"<h4>{title}</h4>", lnL, nfp, *results]
6✔
580
        return "\n".join(results)
6✔
581

582
    def __repr__(self) -> str:
6✔
583
        return str(self)
6✔
584

585
    def __str__(self) -> str:
6✔
586
        title, results = self._for_display()
6✔
587

588
        try:
6✔
589
            lnL = f"log-likelihood = {self.get_log_likelihood():.4f}"
6✔
590
        except ValueError:
6✔
591
            # alignment probably not yet set
592
            lnL = None
6✔
593

594
        nfp = "number of free parameters = %d" % self.get_num_free_params()
6✔
595
        for table_ in results:
6✔
596
            table_.title = ""
6✔
597

598
        results = [title, lnL, nfp, *results] if lnL else [title, nfp, *results]
6✔
599
        return "\n".join(map(str, results))
6✔
600

601
    def get_annotated_tree(self, length_as: str | None = None) -> PhyloNode:
6✔
602
        """returns tree with model attributes on node.params
603

604
        length_as : str or None
605
            replaces 'length' param with either 'ENS' or 'paralinear'.
606
            'ENS' is the expected number of substitution, (which will be
607
            different to standard length if the substitution model is
608
            non-stationary). 'paralinear' is the measure of Lake 1994.
609

610
        The other measures are always available in the params dict of each
611
        node.
612
        """
613
        from cogent3.evolve.ns_substitution_model import (
6✔
614
            DiscreteSubstitutionModel,
615
        )
616

617
        is_discrete = isinstance(self.model, DiscreteSubstitutionModel)
6✔
618

619
        if is_discrete and length_as != "paralinear":
6✔
UNCOV
620
            msg = f"{length_as} invalid for discrete time process"
×
621
            raise ValueError(msg)
×
622

623
        assert length_as in ("ENS", "paralinear", None)
6✔
624
        d = self.get_param_value_dict(["edge"])
6✔
625
        lengths = d.pop("length", None)
6✔
626
        mprobs = self.get_motif_probs_by_node()
6✔
627

628
        ens = {} if is_discrete else self.get_lengths_as_ens(motif_probs=mprobs)
6✔
629

630
        plin = self.get_paralinear_metric(motif_probs=mprobs)
6✔
631
        if length_as == "ENS":
6✔
632
            lengths = ens
6✔
633
        elif length_as == "paralinear":
6✔
634
            lengths = plin
6✔
635

636
        tree = self._tree.deepcopy()
6✔
637
        for edge in tree.get_edge_vector():
6✔
638
            if edge.name == "root":
6✔
639
                edge.params["mprobs"] = mprobs[edge.name].to_dict()
6✔
640
                continue
6✔
641

642
            edge.params["ENS"] = ens.get(edge.name)
6✔
643
            edge.params["length"] = lengths[edge.name]
6✔
644
            edge.params["paralinear"] = plin[edge.name]
6✔
645
            edge.params["mprobs"] = mprobs[edge.name].to_dict()
6✔
646
            for par in d:
6✔
647
                val = d[par].get(edge.name)
6✔
648
                if par == length_as:
6✔
UNCOV
649
                    val = ens[edge.name]
×
650
                edge.params[par] = val
6✔
651

652
        return tree
6✔
653

654
    def get_ens_tree(self) -> PhyloNode:
6✔
655
        """returns tree with length as ENS
656

657
        Notes
658
        -----
659
        The paralinear distance is added to node.params["paralinear"].
660

661
        If it's a discrete-time model, branch lengths are set to None.
662

663
        For a stationary model, branch lengths will be unchanged from
664
        those values displayed in the statistics tables.
665
        """
666
        from cogent3.evolve.ns_substitution_model import (
6✔
667
            DiscreteSubstitutionModel,
668
        )
669

670
        mprobs = self.get_motif_probs_by_node()
6✔
671
        if isinstance(self.model, DiscreteSubstitutionModel):
6✔
672
            msg = "cannot get ENS for discrete-time models"
6✔
673
            raise TypeError(msg)
6✔
674

675
        ens = self.get_lengths_as_ens(motif_probs=mprobs)
6✔
676

677
        tree = self._tree.deepcopy()
6✔
678
        for edge in tree.get_edge_vector(include_root=False):
6✔
679
            edge.params["length"] = ens[edge.name]
6✔
680

681
        return tree
6✔
682

683
    def get_motif_probs(self, edge=None, bin=None, locus=None, position=None):
6✔
684
        """
685
        Parameters
686
        ----------
687
        edge : str
688
            name of edge
689
        bin : int or str
690
            name of bin
691
        locus : str
692
            name of locus
693
        position : int or str
694
            name of position
695

696
        Returns
697
        -------
698
        If 1D, returns DictArray, else a dict of DictArray
699
        """
700
        param_names = self.get_param_names()
6✔
701
        mprob_name = next(n for n in param_names if "mprob" in n)
6✔
702
        dims = tuple(self.get_used_dimensions(mprob_name))
6✔
703
        mprobs = self.get_param_value_dict(dimensions=dims, params=[mprob_name])
6✔
704
        if len(dims) == 2:
6✔
705
            var = next(c for c in dims if c != mprob_name)
6✔
706
            key = locals().get(var, None)
6✔
707
            mprobs = mprobs[mprob_name]
6✔
708
            if key is not None:
6✔
709
                mprobs = mprobs.get(str(key), mprobs.get(key))
6✔
710
                mprobs = {mprob_name: mprobs}
6✔
711

712
        # these can fall below the minimum allowed value due to
713
        # rounding errors, so I adjust these
714
        for value in mprobs.values():
6✔
715
            value.array = adjusted_gt_minprob(value.array, minprob=1e-6)
6✔
716

717
        if len(mprobs) == 1:
6✔
718
            mprobs = mprobs[mprob_name]
6✔
719

720
        return mprobs
6✔
721

722
    def get_bin_prior_probs(self, locus=None):
6✔
UNCOV
723
        bin_probs_array = self.get_param_value("bprobs", locus=locus)
×
724
        return DictArrayTemplate(self.bin_names).wrap(bin_probs_array)
×
725

726
    def get_scaled_lengths(self, predicate, bin=None, locus=None):
6✔
727
        """A dictionary of {scale:{edge:length}}"""
728
        if not hasattr(self._model, "get_scaled_lengths_from_Q"):
6✔
UNCOV
729
            return {}
×
730

731
        get_value_of = self.get_param_value
6✔
732
        value_of_kw = {"locus": locus}
6✔
733

734
        bin_names = self.bin_names if bin is None else [bin]
6✔
735

736
        bprobs = [1.0] if len(bin_names) == 1 else get_value_of("bprobs", **value_of_kw)
6✔
737

738
        mprobs = [get_value_of("mprobs", bin=b, **value_of_kw) for b in bin_names]
6✔
739

740
        scaled_lengths = {}
6✔
741
        for edge in self._tree.get_edge_vector():
6✔
742
            if edge.isroot():
6✔
743
                continue
6✔
744
            Qs = [
6✔
745
                get_value_of("Qd", bin=b, edge=edge.name, **value_of_kw).Q
746
                for b in bin_names
747
            ]
748
            length = get_value_of("length", edge=edge.name, **value_of_kw)
6✔
749
            scaled_lengths[edge.name] = length * self._model.get_scale_from_Qs(
6✔
750
                Qs,
751
                bprobs,
752
                mprobs,
753
                predicate,
754
            )
755
        return scaled_lengths
6✔
756

757
    def get_paralinear_metric(self, motif_probs=None):
6✔
758
        """returns {edge.name: paralinear, ...}
759
        Parameters
760
        ----------
761
        motif_probs : dict or DictArray
762
            an item for each edge of the tree. Computed if not provided.
763
        """
764
        if motif_probs is None:
6✔
765
            motif_probs = self.get_motif_probs_by_node()
6✔
766

767
        plin = {}
6✔
768
        for edge in self.tree.get_edge_vector(include_root=False):
6✔
769
            parent_name = edge.parent.name
6✔
770
            pi = motif_probs[parent_name]
6✔
771
            P = self.get_psub_for_edge(edge.name)
6✔
772
            try:
6✔
773
                Q = self.get_rate_matrix_for_edge(edge.name, calibrated=False)
6✔
774
            except InvalidScopeError:
6✔
775
                Q = None
6✔
776

777
            if Q is None:
6✔
778
                para = paralinear_discrete_time(P.array, pi.array)
6✔
779
            else:
780
                para = paralinear_continuous_time(P.array, pi.array, Q.array)
6✔
781

782
            plin[edge.name] = para
6✔
783

784
        return plin
6✔
785

786
    def get_lengths_as_ens(self, motif_probs=None):
6✔
787
        """returns {edge.name: ens, ...} where ens is the expected number of substitutions
788

789
        for a stationary Markov process, this is just branch length
790

791
        Parameters
792
        ----------
793
        motif_probs : dict or DictArray
794
            an item for each edge of the tree. Computed if not provided.
795
        """
796
        from cogent3.evolve.ns_substitution_model import (
6✔
797
            DiscreteSubstitutionModel,
798
        )
799

800
        if motif_probs is None:
6✔
801
            motif_probs = self.get_motif_probs_by_node()
6✔
802

803
        edge_parent = self.tree.child_parent_map()
6✔
804
        lengths = {}
6✔
805
        for e in edge_parent:
6✔
806
            try:
6✔
807
                length = self.get_param_value("length", edge=e)
6✔
808
            except (InvalidScopeError, KeyError):
6✔
809
                length = None
6✔
810
            lengths[e] = length
6✔
811

812
        if isinstance(self.model, DiscreteSubstitutionModel):
6✔
813
            return lengths
6✔
814

815
        if not isinstance(self.model, substitution_model.Stationary):
6✔
816
            ens = {}
6✔
817
            for e in edge_parent:
6✔
818
                Q = self.get_rate_matrix_for_edge(e)
6✔
819
                length = expected_number_subs(
6✔
820
                    motif_probs[edge_parent[e]],
821
                    Q,
822
                    lengths[e],
823
                )
824
                ens[e] = length
6✔
825

826
            lengths = ens
6✔
827

828
        return lengths
6✔
829

830
    def get_param_rules(self):
6✔
831
        """returns the [{rule}, ..] that would allow reconstruction"""
832
        # markov model rate terms
833
        rules = []
6✔
834
        param_names = self.get_param_names()
6✔
835
        for param_name in param_names:
6✔
836
            defn = self.defn_for[param_name]
6✔
837
            try:
6✔
838
                rules.extend(defn.get_param_rules())
6✔
839
            except AttributeError:
6✔
840
                # aggregate params, like those deriving from gamma shaped rates
841
                pass
6✔
842

843
        return rules
6✔
844

845
    def get_statistics(self, with_motif_probs=True, with_titles=True):
6✔
846
        """returns the parameter values as tables/dict
847

848
        Parameters
849
        ----------
850
        with_motif_probs
851
            include the motif probability table
852
        with_titles
853
            include a title for each table based on it's
854
            dimension
855

856
        """
857
        result = []
6✔
858
        group = {}
6✔
859
        param_names = self.get_param_names()
6✔
860

861
        mprob_name = [n for n in param_names if "mprob" in n]
6✔
862
        mprob_name = mprob_name[0] if mprob_name else ""
6✔
863
        if not with_motif_probs:
6✔
864
            param_names.remove(mprob_name)
6✔
865

866
        for param in param_names:
6✔
867
            dims = tuple(self.get_used_dimensions(param))
6✔
868
            if dims not in group:
6✔
869
                group[dims] = []
6✔
870
            group[dims].append(param)
6✔
871
        table_order = list(group.keys())
6✔
872
        table_order.sort()
6✔
873
        for table_dims in table_order:
6✔
874
            raw_table = self.get_param_value_dict(
6✔
875
                dimensions=table_dims,
876
                params=group[table_dims],
877
            )
878
            param_names = group[table_dims]
6✔
879
            param_names.sort()
6✔
880
            if table_dims == ("edge",):
6✔
881
                if "length" in param_names:
6✔
882
                    param_names.remove("length")
6✔
883
                    param_names.insert(0, "length")
6✔
884
                raw_table["parent"] = {
6✔
885
                    e.name: e.parent.name
886
                    for e in self._tree.get_edge_vector()
887
                    if not e.isroot()
888
                }
889
                param_names.insert(0, "parent")
6✔
890
            list_table = []
6✔
891
            heading_names = list(table_dims) + param_names
6✔
892
            row_order = self._valuesForDimensions(table_dims)
6✔
893
            for scope in row_order:
6✔
894
                row = {}
6✔
895
                row_used = False
6✔
896
                for param in param_names:
6✔
897
                    d = raw_table[param]
6✔
898
                    try:
6✔
899
                        for part in scope:
6✔
900
                            d = d[part]
6✔
901
                    except KeyError:
6✔
902
                        d = "NA"
6✔
903
                    else:
904
                        row_used = True
6✔
905
                    row[param] = d
6✔
906
                if row_used:
6✔
907
                    row.update(dict(list(zip(table_dims, scope, strict=False))))
6✔
908
                    row = [row[k] for k in heading_names]
6✔
909
                    list_table.append(row)
6✔
910
            if table_dims:
6✔
911
                title = ["", f"{' '.join(table_dims)} params"][with_titles]
6✔
912
            else:
913
                title = ["", "global params"][with_titles]
6✔
914
            row_ids = None
6✔
915
            stat_table = table.Table(
6✔
916
                heading_names,
917
                list_table,
918
                max_width=80,
919
                index_name=row_ids,
920
                title=title,
921
                **self._format,
922
            )
923
            if group[table_dims] == [mprob_name]:
6✔
924
                # if stat_table.shape
925
                # if mprobs, we use the motifs as header
926
                motifs = sorted(set(stat_table.to_list("motif")))
6✔
927
                if stat_table.shape[1] == 2:
6✔
928
                    motif_prob = dict(stat_table.to_list())
6✔
929
                    heading_names = motifs
6✔
930
                    list_table = [motif_prob[m] for m in motifs]
6✔
931
                    list_table = [list_table]
6✔
932
                elif stat_table.shape[1] == 3:
6✔
933
                    rows = []
6✔
934
                    other_col = next(
6✔
935
                        c
936
                        for c in stat_table.header
937
                        if "motif" not in c and "mprobs" not in c
938
                    )
939
                    for val in stat_table.distinct_values(other_col):
6✔
940
                        subtable = stat_table.filtered(
6✔
941
                            lambda x: x == val,
942
                            columns=other_col,
943
                        )
944
                        motif_prob = dict(
6✔
945
                            subtable.to_list(
946
                                [c for c in stat_table.header if c != other_col],
947
                            ),
948
                        )
949
                        rows.append([val] + [motif_prob[m] for m in motifs])
6✔
950
                    heading_names = [other_col, *motifs]
6✔
951
                    list_table = rows
6✔
952
                stat_table = table.Table(
6✔
953
                    heading_names,
954
                    list_table,
955
                    max_width=80,
956
                    title=title,
957
                    **self._format,
958
                )
959

960
            stat_table = _update_stat_table_col_formatting(stat_table)
6✔
961
            result.append(stat_table)
6✔
962
        return result
6✔
963

964
    def to_rich_dict(self):
6✔
965
        """returns detailed info on object, used by to_json"""
966
        data = deepcopy(self._serialisable)
6✔
967
        for key in ("model", "tree"):
6✔
968
            del data[key]
6✔
969

970
        tree = self.tree.to_rich_dict()
6✔
971
        edge_attr = tree["edge_attributes"]
6✔
972
        for edge in edge_attr:
6✔
973
            if edge == "root":
6✔
974
                continue
6✔
975
            try:
6✔
976
                edge_attr[edge]["length"] = self.get_param_value("length", edge=edge)
6✔
977
            except KeyError:
6✔
978
                # probably discrete-time model
979
                edge_attr[edge]["length"] = None
6✔
980

981
        model = self._model.to_rich_dict(for_pickle=False)
6✔
982

983
        aln_defn = self.defn_for["alignment"]
6✔
984
        if len(aln_defn.index) == 1:
6✔
985
            alignment = self.get_param_value("alignment").to_rich_dict()
6✔
986
            mprobs = self.get_motif_probs().to_dict()
6✔
987
        else:
988
            # this is a multi-locus likelihood function
989
            alignment = {a["locus"]: a["value"] for a in aln_defn.get_param_rules()}
6✔
990
            for k in alignment:
6✔
991
                alignment[k] = alignment[k].to_rich_dict()
6✔
992

993
            mprobs = self.get_motif_probs()
6✔
994
            if isinstance(mprobs, dict):
6✔
995
                # separate mprobs per locus
996
                for k in alignment:
6✔
997
                    mprobs[k] = mprobs[k].to_dict()
6✔
998
            else:
999
                # motif probs are constrained to be the same between loci
1000
                mprobs = self.get_motif_probs().to_dict()
6✔
1001

1002
        DLC = self.all_psubs_DLC()
6✔
1003
        try:
6✔
1004
            unique_Q = self.all_rate_matrices_unique()
6✔
1005
        except Exception:
6✔
1006
            # there's a mix of assertions
1007
            # for "storage", make this indeterminate in those cases
1008
            unique_Q = None
6✔
1009

1010
        return {
6✔
1011
            "model": model,
1012
            "tree": tree,
1013
            "alignment": alignment,
1014
            "likelihood_construction": data,
1015
            "param_rules": self.get_param_rules(),
1016
            "lnL": self.get_log_likelihood(),
1017
            "nfp": self.get_num_free_params(),
1018
            "motif_probs": mprobs,
1019
            "DLC": DLC,
1020
            "unique_Q": unique_Q,
1021
            "type": get_object_provenance(self),
1022
            "name": self.get_name(),
1023
            "version": __version__,
1024
        }
1025

1026
    def to_json(self):
6✔
1027
        data = self.to_rich_dict()
6✔
1028
        return json.dumps(data)
6✔
1029

1030
    @property
6✔
1031
    def name(self):
6✔
1032
        if self._name is None:
6✔
1033
            self._name = self.model.name or ""
6✔
1034

1035
        return self._name
6✔
1036

1037
    @name.setter
6✔
1038
    def name(self, name) -> None:
6✔
1039
        self._name = name
6✔
1040

1041
    # For tests.  Compat with old LF interface
1042
    def set_name(self, name) -> None:
6✔
1043
        self.name = name
6✔
1044

1045
    def get_name(self):
6✔
1046
        return self.name
6✔
1047

1048
    def set_tables_format(self, space=4, digits=4) -> None:
6✔
1049
        """sets display properties for statistics tables. This affects results
1050
        of str(lf) too."""
1051
        space = [space, 4][type(space) != int]
6✔
1052
        digits = [digits, 4][type(digits) != int]
6✔
1053
        self._format = {"space": space, "digits": digits}
6✔
1054

1055
    def _get_motif_probs_by_node_tr(self, edges=None, bin=None, locus=None):
6✔
1056
        """returns motif probs by node for time-reversible models"""
1057
        mprob_rules = [r for r in self.get_param_rules() if "mprob" in r["par_name"]]
6✔
1058
        if len(mprob_rules) > 1 or self.model.mprob_model == "monomers":
6✔
1059
            raise NotImplementedError
6✔
1060

1061
        mprobs = self.get_motif_probs()
6✔
1062
        if len(mprobs) != len(self.motifs):
6✔
1063
            # a Muse and Gaut model
1064
            expanded = numpy.zeros(len(self.motifs), dtype=float)
6✔
1065
            for i, motif in enumerate(self.motifs):
6✔
1066
                val = 1.0
6✔
1067
                for b in motif:
6✔
1068
                    val *= mprobs[b]
6✔
1069
                expanded[i] = val
6✔
1070
            mprobs = expanded / expanded.sum()
6✔
1071
        else:
1072
            mprobs = [mprobs[m] for m in self.motifs]
6✔
1073
        edges = []
6✔
1074
        values = []
6✔
1075
        for e in self.tree.postorder():
6✔
1076
            edges.append(e.name)
6✔
1077
            values.append(mprobs)
6✔
1078

1079
        return DictArrayTemplate(edges, self.motifs).wrap(values)
6✔
1080

1081
    def get_motif_probs_by_node(self, edges=None, bin=None, locus=None):
6✔
1082
        from cogent3.evolve.substitution_model import TimeReversible
6✔
1083

1084
        if isinstance(self.model, TimeReversible):
6✔
1085
            return self._get_motif_probs_by_node_tr(edges=edges, bin=bin, locus=locus)
6✔
1086

1087
        kw = {"bin": bin, "locus": locus}
6✔
1088
        mprobs = self.get_param_value("mprobs", **kw)
6✔
1089
        mprobs = self._model.calc_word_probs(mprobs)
6✔
1090
        result = self._nodeMotifProbs(self._tree, mprobs, kw)
6✔
1091
        if edges is None:
6✔
1092
            edges = [name for (name, m) in result]
6✔
1093
        result = dict(result)
6✔
1094
        values = [result[name] for name in edges]
6✔
1095
        return DictArrayTemplate(edges, self._mprob_motifs).wrap(values)
6✔
1096

1097
    def _nodeMotifProbs(self, tree, mprobs, kw):
6✔
1098
        result = [(tree.name, mprobs)]
6✔
1099
        for child in tree.children:
6✔
1100
            psub = self.get_psub_for_edge(child.name, **kw)
6✔
1101
            child_mprobs = numpy.dot(mprobs, psub)
6✔
1102
            result.extend(self._nodeMotifProbs(child, child_mprobs, kw))
6✔
1103
        return result
6✔
1104

1105
    def simulate_alignment(
6✔
1106
        self,
1107
        sequence_length=None,
1108
        random_series=None,
1109
        exclude_internal=True,
1110
        locus=None,
1111
        seed=None,
1112
        root_sequence=None,
1113
    ):
1114
        """
1115
        Returns an alignment of simulated sequences with key's corresponding to
1116
        names from the current attached alignment.
1117

1118
        Parameters
1119
        ----------
1120
        sequence_length
1121
            the length of the alignment to be simulated,
1122
            default is the length of the attached alignment.
1123
        random_series
1124
            a random number generator.
1125
        exclude_internal
1126
            if True, only sequences for tips are returned.
1127
        locus
1128
            if fit to multiple alignments, select the values corresponding to
1129
            locus for generating data
1130
        seed
1131
            seed value for the random number generator
1132
        root_sequence
1133
            a sequence from which all others evolve
1134
        """
1135
        orig_ambig = {}
6✔
1136
        if sequence_length is None:
6✔
1137
            lht = self.get_param_value("lht", locus=locus)
6✔
1138
            try:
6✔
1139
                sequence_length = len(lht.index)
6✔
1140
            except AttributeError:
6✔
1141
                msg = "Must provide sequence_length since no alignment set on self"
6✔
1142
                raise ValueError(
6✔
1143
                    msg,
1144
                )
1145

1146
            leaves = self.get_param_value("leaf_likelihoods", locus=locus)
6✔
1147
            for seq_name, leaf in list(leaves.items()):
6✔
1148
                orig_ambig[seq_name] = leaf.get_ambiguous_positions()
6✔
1149

1150
        if random_series is None:
6✔
1151
            random_series = random.Random()
6✔
1152
            random_series.seed(seed)
6✔
1153

1154
        def psub_for(edge, bin):
6✔
1155
            return self.get_psub_for_edge(edge, bin=bin, locus=locus)
6✔
1156

1157
        if len(self.bin_names) > 1:
6✔
1158
            hmm = self.get_param_value("bdist", locus=locus)
6✔
1159
            site_bins = hmm.emit(sequence_length, random_series)
6✔
1160
        else:
1161
            site_bins = numpy.zeros([sequence_length], int)
6✔
1162

1163
        evolver = AlignmentEvolver(
6✔
1164
            random_series,
1165
            orig_ambig,
1166
            exclude_internal,
1167
            self.bin_names,
1168
            site_bins,
1169
            psub_for,
1170
            self._motifs,
1171
        )
1172

1173
        if root_sequence is not None:  # we convert to a vector of motifs
6✔
1174
            if isinstance(root_sequence, str):
6✔
1175
                root_sequence = self._model.moltype.make_seq(seq=root_sequence)
6✔
1176
            motif_len = self._model.get_alphabet().motif_len
6✔
1177
            root_sequence = root_sequence.get_in_motif_size(motif_len)
6✔
1178
        else:
1179
            mprobs = self.get_param_value("mprobs", locus=locus, edge="root")
6✔
1180
            mprobs = self._model.calc_word_probs(mprobs)
6✔
1181
            mprobs = dict(zip(self._motifs, mprobs, strict=False))
6✔
1182
            root_sequence = random_sequence(random_series, mprobs, sequence_length)
6✔
1183

1184
        simulated_sequences = evolver(self._tree, root_sequence)
6✔
1185

1186
        return cogent3.make_aligned_seqs(
6✔
1187
            simulated_sequences,
1188
            moltype=self._model.moltype,
1189
        )
1190

1191
    def all_psubs_DLC(self) -> bool:
6✔
1192
        """Returns True if every Psub matrix is Diagonal Largest in Column"""
1193
        all_psubs = self.get_all_psubs()
6✔
1194
        return all(not (P.to_array().diagonal() < P).any() for P in all_psubs.values())
6✔
1195

1196
    def all_rate_matrices_unique(self) -> bool:
6✔
1197
        """Returns True if every rate matrix is unique for its Psub matrix"""
1198
        # get all possible Q, as products of t, and any rate-het terms
1199
        all_Q = self.get_all_rate_matrices(calibrated=False)
6✔
1200
        for Q in all_Q.values():
6✔
1201
            Q = Q.to_array()
6✔
1202
            if not is_generator_unique(Q):
6✔
UNCOV
1203
                return False
×
1204
        return True
6✔
1205

1206
    def initialise_from_nested(self, nested_lf) -> None:
6✔
1207
        from cogent3.evolve.substitution_model import Stationary
6✔
1208

1209
        assert self.get_num_free_params() > nested_lf.get_num_free_params(), (
6✔
1210
            "wrong order for likelihood functions"
1211
        )
1212
        compatible_likelihood_functions(self, nested_lf)
6✔
1213

1214
        same = (
6✔
1215
            isinstance(self.model, Stationary)
1216
            and isinstance(nested_lf.model, Stationary)
1217
        ) or (
1218
            not isinstance(self.model, Stationary)
1219
            and not isinstance(nested_lf.model, Stationary)
1220
        )
1221

1222
        mprobs = nested_lf.get_motif_probs()
6✔
1223
        edge_names = self.tree.get_node_names()
6✔
1224
        edge_names.remove("root")
6✔
1225
        param_proj = _ParamProjection(nested_lf.model, self.model, mprobs, same=same)
6✔
1226
        param_rules = nested_lf.get_param_rules()
6✔
1227
        param_rules = param_proj.update_param_rules(param_rules)
6✔
1228
        my_rules = self.get_param_rules()
6✔
1229
        my_rules = update_scoped_rules(my_rules, param_rules)
6✔
1230
        self.apply_param_rules(my_rules)
6✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc