From 261d96eab2659d1dbcdc876cbd473323c1a65bec Mon Sep 17 00:00:00 2001
From: Roman Ludwig <48687784+rmnldwg@users.noreply.github.com>
Date: Wed, 29 May 2024 16:56:05 +0200
Subject: [PATCH 1/7] build: remove upper cap in deps
---
pyproject.toml | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
diff --git a/pyproject.toml b/pyproject.toml
index 6d8e8dd..1cf5b6b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -27,17 +27,17 @@ classifiers = [
"Topic :: Scientific/Engineering",
]
dependencies = [
- "numpy < 2",
- "pandas < 3",
- "cachetools < 6",
+ "numpy",
+ "pandas",
+ "cachetools",
]
dynamic = ["version"]
[project.optional-dependencies]
test = [
- "scipy < 2",
- "coverage < 8",
- "emcee < 4",
+ "scipy",
+ "coverage",
+ "emcee",
]
dev = [
"pre-commit",
From b51d3d1b0fda9f65530f08af661519844d218f2e Mon Sep 17 00:00:00 2001
From: Roman Ludwig <48687784+rmnldwg@users.noreply.github.com>
Date: Wed, 29 May 2024 16:57:16 +0200
Subject: [PATCH 2/7] chore: bump pre-commit versions
---
.pre-commit-config.yaml | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 331c1a9..638a80c 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
- rev: v4.4.0
+ rev: v4.6.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
@@ -8,26 +8,26 @@ repos:
- id: check-toml
- id: check-yaml
- repo: https://github.com/hadialqattan/pycln
- rev: v2.1.5
+ rev: v2.4.0
hooks:
- id: pycln
args: [--config=pyproject.toml]
- repo: https://github.com/PyCQA/isort
- rev: 5.12.0
+ rev: 5.13.2
hooks:
- id: isort
files: "\\.(py)$"
args: [--settings-path=pyproject.toml]
- repo: https://github.com/asottile/pyupgrade
- rev: v3.9.0
+ rev: v3.15.2
hooks:
- id: pyupgrade
- repo: https://github.com/kynan/nbstripout
- rev: 0.6.0
+ rev: 0.7.1
hooks:
- id: nbstripout
- repo: https://github.com/compilerla/conventional-pre-commit
- rev: v2.3.0
+ rev: v3.2.0
hooks:
- id: conventional-pre-commit
stages: [commit-msg]
From 7f242825095c313ccb42cdd294f685e7299837dd Mon Sep 17 00:00:00 2001
From: Roman Ludwig <48687784+rmnldwg@users.noreply.github.com>
Date: Thu, 20 Jun 2024 16:13:47 +0200
Subject: [PATCH 3/7] fix(mid): correct contra state dist evo
Previously, the model did not correctly marginalize over the possible
time when a tumor can grow over the midline. It simply assumed that it
did from the onset.
Fixes: #85
---
lymph/models/midline.py | 58 +++++++++++++++++++++++++++++++----------
1 file changed, 44 insertions(+), 14 deletions(-)
diff --git a/lymph/models/midline.py b/lymph/models/midline.py
index 74bc43b..aa2fba8 100644
--- a/lymph/models/midline.py
+++ b/lymph/models/midline.py
@@ -518,33 +518,63 @@ def load_patient_data(
def midext_evo(self) -> np.ndarray:
"""Evolve only the state of the midline extension."""
+ time_steps = np.arange(self.max_time + 1)
midext_states = np.zeros(shape=(self.max_time + 1, 2), dtype=float)
- midext_states[0,0] = 1.
-
- midextransition_matrix = np.array([
- [1 - self.midext_prob, self.midext_prob],
- [0. , 1. ],
- ])
-
- # compute involvement for all time steps
- for i in range(len(midext_states)-1):
- midext_states[i+1,:] = midext_states[i,:] @ midextransition_matrix
+ midext_states[:,0] = (1. - self.midext_prob) ** time_steps
+ midext_states[:,1] = 1. - midext_states[:,0]
return midext_states
def contra_state_dist_evo(self) -> tuple[np.ndarray, np.ndarray]:
- """Evolve contra side as mixture of with & without midline extension."""
+ """Evolve contra side as mixture of with & without midline extension.
+
+ This computes the evolution of the contralateral state distribution for both
+ absent and present midline extension and returns them as a tuple.
+
+ The first element of the tuple is the evolution of the contralateral state
+ distribution while having no midline extension. This means that e.g. the value
+ at index ``[t,i]`` is the probability of being in state ``i`` at time ``t``,
+ **AND** not having midline extension after these ``t`` time steps.
+
+ The second element of the tuple is the evolution of the contralateral state
+ distribution where midline extension occurs at some time point. For example,
+ the value at index ``[t,i]`` is the probability of being in state ``i`` at time
+ ``t``, **AND** having developed midline extension at some time point before.
+
+ To compute this second evolution, we need to mix the model without and with
+ midline extension at each time step, following a recusion formula.
+ """
noext_contra_dist_evo = self.noext.contra.state_dist_evo()
- ext_contra_dist_evo = self.ext.contra.state_dist_evo()
if not self.use_midext_evo:
+ ext_contra_dist_evo = self.ext.contra.state_dist_evo()
noext_contra_dist_evo *= (1. - self.midext_prob)
ext_contra_dist_evo *= self.midext_prob
else:
+ ext_contra_dist_evo = np.zeros_like(noext_contra_dist_evo)
midext_evo = self.midext_evo()
- noext_contra_dist_evo *= midext_evo[:,0].reshape((-1, 1))
- ext_contra_dist_evo *= midext_evo[:,1].reshape((-1, 1))
+
+ # Evolution of contralateral state dists, given no midline extension,
+ # multiplied with the probabilities of having no midline extension at all
+ # time steps, resulting in a vector of length `max_time + 1`:
+ # P(X_c[t] | noext) * P(noext | t)
+ noext_contra_dist_evo *= midext_evo[:,0].reshape(-1, 1)
+
+ for t in range(self.max_time):
+ # For the case of midline extension, we need to consider all possible
+ # paths that lead to a midline extension at time t+1. We can define
+ # this recursively:
+ ext_contra_dist_evo[t + 1] = (
+ # it's the probability of developing it just now, in which case we
+ # use the noext state and multiply it with the probability to
+ # develop midline extension at this time step, ...
+ self.midext_prob * noext_contra_dist_evo[t]
+ # ... plus the probability of having it already, in which case we
+ # use the ext state. The probability of "keeping" the midline
+ # extension is 1, so we don't need to multiply it with anything.
+ + ext_contra_dist_evo[t]
+ ) @ self.ext.contra.transition_matrix() # then evolve using ext model
return noext_contra_dist_evo, ext_contra_dist_evo
From 1f1fef2e3b12968631daf3092acc5e2d2244a564 Mon Sep 17 00:00:00 2001
From: Roman Ludwig <48687784+rmnldwg@users.noreply.github.com>
Date: Tue, 25 Jun 2024 14:33:16 +0200
Subject: [PATCH 4/7] change: `risk()` meth requires `involvement`
We figured it does not make sense to allow passing `involvement=None`
into the `risk()` method just to have it return 1. This is except for
the midline class, where `involvement` may reasonably be `None` while
`midext` isn't.
Also, I ran ruff over some files, fixing some code style issues.
Fixes: #87
---
lymph/models/__init__.py | 3 +-
lymph/models/bilateral.py | 46 ++++++++++++++++++++----------
lymph/models/midline.py | 24 ++++++++++++----
lymph/models/unilateral.py | 47 ++++++++++++++++++-------------
lymph/types.py | 9 ++++--
lymph/utils.py | 7 +++--
pyproject.toml | 3 ++
tests/bayesian_unilateral_test.py | 3 +-
tests/binary_bilateral_test.py | 3 +-
tests/binary_midline_test.py | 4 +--
tests/doc_test.py | 4 +--
tests/emcee_intergration_test.py | 3 +-
tests/fixtures.py | 7 +++--
tests/integration_test.py | 7 +++--
tests/trinary_midline_test.py | 3 +-
15 files changed, 106 insertions(+), 67 deletions(-)
diff --git a/lymph/models/__init__.py b/lymph/models/__init__.py
index fde9311..6e4f916 100644
--- a/lymph/models/__init__.py
+++ b/lymph/models/__init__.py
@@ -1,5 +1,4 @@
-"""
-This module implements the core classes to model lymphatic tumor progression.
+"""This module implements the core classes to model lymphatic tumor progression.
"""
from lymph.models.bilateral import Bilateral
from lymph.models.midline import Midline
diff --git a/lymph/models/bilateral.py b/lymph/models/bilateral.py
index 2ecaa15..8148c9a 100644
--- a/lymph/models/bilateral.py
+++ b/lymph/models/bilateral.py
@@ -2,7 +2,8 @@
import logging
import warnings
-from typing import Any, Iterable, Literal
+from collections.abc import Iterable
+from typing import Any, Literal
import numpy as np
import pandas as pd
@@ -25,11 +26,14 @@ class Bilateral(
contralateral side of the neck. The two sides are assumed to be independent of each
other, given the diagnosis time over which we marginalize.
- See Also:
+ See Also
+ --------
:py:class:`~lymph.models.Unilateral`
Two instances of this class are created as attributes. One for the ipsi- and
one for the contralateral side of the neck.
+
"""
+
def __init__(
self,
graph_dict: types.GraphDictType,
@@ -51,6 +55,7 @@ def __init__(
The values are booleans, with ``True`` meaning that the aspect is symmetric.
Note:
+ ----
The symmetries of tumor and LNL spread are only guaranteed if the
respective parameters are set via the :py:meth:`.set_params()` method of
this bilateral model. It is still possible to set different parameters for
@@ -62,6 +67,7 @@ def __init__(
contralateral side, respectively. The ipsi- and contralateral kwargs override
the unilateral kwargs and may also override the ``graph_dict``. This allows the
user to specify different graphs for the two sides of the neck.
+
"""
self._init_models(
graph_dict=graph_dict,
@@ -245,15 +251,17 @@ def get_spread_params(
... ("lnl", "III"): [],
... })
>>> num_dims = model.get_num_dims()
- >>> model.set_spread_params(*np.round(np.linspace(0., 1., num_dims+1), 2))
+ >>> num_dims
+ 5
+ >>> model.set_spread_params(*np.round(np.linspace(0., 1., num_dims+1), 2)) # doctest: +SKIP
(1.0,)
- >>> model.get_spread_params(as_flat=False) # doctest: +NORMALIZE_WHITESPACE
+ >>> model.get_spread_params(as_flat=False) # doctest: +SKIP
{'ipsi': {'TtoII': {'spread': 0.0},
'TtoIII': {'spread': 0.2}},
'contra': {'TtoII': {'spread': 0.4},
'TtoIII': {'spread': 0.6}},
'IItoIII': {'spread': 0.8}}
- >>> model.get_spread_params(as_flat=True) # doctest: +NORMALIZE_WHITESPACE
+ >>> model.get_spread_params(as_flat=True) # doctest: +SKIP
{'ipsi_TtoII_spread': 0.0,
'ipsi_TtoIII_spread': 0.2,
'contra_TtoII_spread': 0.4,
@@ -409,14 +417,16 @@ def state_dist(
This computes the state distributions of both sides and returns their outer
product. In case ``mode`` is ``"HMM"`` (default), the state distributions are
- first marginalized over the diagnosis time distribtions of the respective
+ first marginalized over the diagnosis time distributions of the respective
``t_stage``.
- See Also:
+ See Also
+ --------
:py:meth:`.Unilateral.state_dist`
The corresponding unilateral function. Note that this method returns
a 2D array, because it computes the probability of any possible
combination of ipsi- and contralateral states.
+
"""
if mode == "HMM":
ipsi_state_evo = self.ipsi.state_dist_evo()
@@ -443,11 +453,13 @@ def obs_dist(
) -> np.ndarray:
"""Compute the joint distribution over the ipsi- & contralateral observations.
- See Also:
+ See Also
+ --------
:py:meth:`.Unilateral.obs_dist`
The corresponding unilateral function. Note that this method returns
a 2D array, because it computes the probability of any possible
combination of ipsi- and contralateral observations.
+
"""
if given_state_dist is None:
given_state_dist = self.state_dist(t_stage=t_stage, mode=mode)
@@ -531,12 +543,15 @@ def likelihood(
(``"HMM"``) or the Bayesian network (``"BN"``).
Note:
+ ----
The computation is much faster if no parameters are given, since then the
transition matrix does not need to be recomputed.
See Also:
+ --------
:py:meth:`.Unilateral.likelihood`
The corresponding unilateral function.
+
"""
try:
# all functions and methods called here should raise a ValueError if the
@@ -570,15 +585,17 @@ def posterior_state_dist(
contralateral involvement, given the provided diagnosis.
Warning:
+ -------
As in the :py:meth:`.Unilateral.posterior_state_dist` method, one may
provide a precomputed (joint) state distribution via the ``given_state_dist``
- argument (should be a square matric). In this case, the ``given_params``
+ argument (should be a square matrix). In this case, the ``given_params``
are ignored and the model does not need to recompute e.g. the
:py:meth:`.transition_matrix` or :py:meth:`.state_dist`, making the
computation much faster.
However, this will mean that ``t_stage`` and ``mode`` are also ignored,
since these are only used to compute the state distribution.
+
"""
if given_state_dist is None:
utils.safe_set_params(self, given_params)
@@ -611,7 +628,7 @@ def posterior_state_dist(
def marginalize(
self,
- involvement: dict[str, types.PatternType] | None = None,
+ involvement: dict[str, types.PatternType],
given_state_dist: np.ndarray | None = None,
t_stage: str = "early",
mode: Literal["HMM", "BN"] = "HMM",
@@ -625,9 +642,6 @@ def marginalize(
:py:meth:`.state_dist` with the given ``t_stage`` and ``mode``. These arguments
are ignored if ``given_state_dist`` is provided.
"""
- if involvement is None:
- involvement = {}
-
if given_state_dist is None:
given_state_dist = self.state_dist(t_stage=t_stage, mode=mode)
@@ -648,7 +662,7 @@ def marginalize(
def risk(
self,
- involvement: dict[str, types.PatternType] | None = None,
+ involvement: dict[str, types.PatternType],
given_params: types.ParamsType | None = None,
given_state_dist: np.ndarray | None = None,
given_diagnosis: dict[str, types.DiagnosisType] | None = None,
@@ -688,13 +702,15 @@ def draw_patients(
) -> pd.DataFrame:
"""Draw ``num`` random patients from the parametrized model.
- See Also:
+ See Also
+ --------
:py:meth:`.diagnosis_times.Distribution.draw_diag_times`
Method to draw diagnosis times from a distribution.
:py:meth:`.Unilateral.draw_diagnosis`
Method to draw individual diagnosis from a unilateral model.
:py:meth:`.Unilateral.draw_patients`
The unilateral method to draw a synthetic dataset.
+
"""
if rng is None:
rng = np.random.default_rng(seed)
diff --git a/lymph/models/midline.py b/lymph/models/midline.py
index aa2fba8..2021a89 100644
--- a/lymph/models/midline.py
+++ b/lymph/models/midline.py
@@ -2,7 +2,8 @@
import logging
import warnings
-from typing import Any, Iterable, Literal
+from collections.abc import Iterable
+from typing import Any, Literal
import numpy as np
import pandas as pd
@@ -23,7 +24,7 @@ class Midline(
modalities.Composite,
types.Model,
):
- """Models metastatic progression bilaterally with tumor lateralization.
+ r"""Models metastatic progression bilaterally with tumor lateralization.
Model a bilateral lymphatic system where an additional risk factor can
be provided in the data: Whether or not the primary tumor extended over the
@@ -45,6 +46,7 @@ class Midline(
:math:`b_c^{\\not\\in}` for patients without. :math:`\\alpha` is the linear
mixing parameter.
"""
+
def __init__(
self,
graph_dict: types.GraphDictType,
@@ -84,12 +86,14 @@ def __init__(
The ``uni_kwargs`` are passed to all bilateral models.
- See Also:
+ See Also
+ --------
:py:class:`Bilateral`: Two to four of these are held as attributes by this
class. One for the case of a mid-sagittal extension of the primary
tumor, one for the case of no such extension, (possibly) one for the case of
a central/symmetric tumor, and (possibly) one for the case of unknown
midline extension status.
+
"""
if is_symmetric is None:
is_symmetric = {}
@@ -393,6 +397,7 @@ def set_tumor_spread_params(
for (key, ipsi_param), noext_contra_param in zip(
self.ext.ipsi.get_tumor_spread_params().items(),
self.noext.contra.get_tumor_spread_params().values(),
+ strict=False,
):
ext_contra_kwargs[key] = (
self.mixing_param * ipsi_param
@@ -623,11 +628,13 @@ def obs_dist(
ignored. The provided state distribution may be 2D or 3D. The returned
distribution will have the same dimensionality.
- See Also:
+ See Also
+ --------
:py:meth:`.Unilateral.obs_dist`
The corresponding unilateral function. Note that this method returns
a 2D array, because it computes the probability of any possible
combination of ipsi- and contralateral observations.
+
"""
if given_state_dist is None:
given_state_dist = self.state_dist(t_stage=t_stage, mode=mode, central=central)
@@ -709,12 +716,15 @@ def likelihood(
Bayesian network mode.
Note:
+ ----
The computation is faster if no parameters are given, since then the
transition matrix does not need to be recomputed.
See Also:
+ --------
:py:meth:`.Unilateral.likelihood`
The corresponding unilateral function.
+
"""
try:
# all functions and methods called here should raise a ValueError if the
@@ -747,11 +757,13 @@ def posterior_state_dist(
mid-sagittal line (``midext``), and whether it is central (``central``, only
used if :py:attr:`use_central` is ``True``).
- See Also:
+ See Also
+ --------
:py:meth:`.types.Model.posterior_state_dist`
The corresponding method in the base class.
:py:meth:`.Bilateral.posterior_state_dist`
The bilateral method that is ultimately called by this one.
+
"""
# NOTE: When given a 2D state distribution, it does not matter which of the
# Bilateral models is used to compute the risk, since the state dist is
@@ -847,6 +859,7 @@ def risk(
is thus ignored.
Warning:
+ -------
As in the :py:meth:`.Bilateral.posterior_state_dist` method, you may
provide a precomputed (joint) state distribution in the ``given_state_dist``
argument. Here, this ``given_state_dist`` may be a 2D array, in which case
@@ -857,6 +870,7 @@ def risk(
argument is *not* ignored: It may be used to select the correct state
distribution (when ``True`` or ``False``), or marginalize over the midline
extension status (when ``midext=None``).
+
"""
posterior_state_dist = self.posterior_state_dist(
given_params=given_params,
diff --git a/lymph/models/unilateral.py b/lymph/models/unilateral.py
index 56644fd..f92f226 100644
--- a/lymph/models/unilateral.py
+++ b/lymph/models/unilateral.py
@@ -1,8 +1,9 @@
from __future__ import annotations
import warnings
+from collections.abc import Callable, Iterable
from itertools import product
-from typing import Any, Callable, Iterable, Literal
+from typing import Any, Literal
import numpy as np
import pandas as pd
@@ -11,10 +12,10 @@
from lymph import diagnosis_times, graph, matrix, modalities, types, utils
# pylint: disable=unused-import
+from lymph.utils import dict_to_func # noqa: F401
+from lymph.utils import draw_diagnosis # noqa: F401
from lymph.utils import ( # nopycln: import
add_or_mult,
- dict_to_func,
- draw_diagnosis,
early_late_mapping,
flatten,
get_params_from,
@@ -41,6 +42,7 @@ class Unilateral(
of this class allow to calculate the probability of a certain hidden pattern of
involvement, given an individual diagnosis of a patient.
"""
+
def __init__(
self,
graph_dict: types.GraphDictType,
@@ -57,6 +59,7 @@ def __init__(
names of the nodes that are connected to the node given by the key.
Note:
+ ----
Do make sure the values in the dictionary are of type ``list`` and *not*
``set``. Sets do not preserve the order of the elements and thus the order
of the edges in the graph. This may lead to inconsistencies in the model.
@@ -90,6 +93,7 @@ def __init__(
whether the microscopic involvement and growth parameters are shared among all
LNLs. If they are set to ``True``, the parameters are set globally for all LNLs.
If they are set to ``False``, the parameters are set individually for each LNL.
+
"""
self.graph = graph.Representation(
graph_dict=graph_dict,
@@ -296,7 +300,7 @@ def transition_prob(
newstate: list[int],
assign: bool = False
) -> float:
- """Computes probability to transition to ``newstate``, given its current state.
+ """Compute probability to transition to ``newstate``, given its current state.
The probability is computed as the product of the transition probabilities of
the individual LNLs. If ``assign`` is ``True``, the new state is assigned to
@@ -384,7 +388,7 @@ def obs_list(self):
def transition_matrix(self) -> np.ndarray:
- """Matrix encoding the probabilities to transition from one state to another.
+ r"""Matrix encoding the probabilities to transition from one state to another.
This is the crucial object for modelling the evolution of the probabilistic
system in the context of the hidden Markov model. It has the shape
@@ -393,7 +397,8 @@ def transition_matrix(self) -> np.ndarray:
transition from the :math:`i`-th state to the :math:`j`-th state. The states
are ordered as in the :py:attr:`.graph.Representation.state_list`.
- See Also:
+ See Also
+ --------
:py:func:`~lymph.descriptors.matrix.generate_transition`
The function actually computing the transition matrix.
@@ -409,6 +414,7 @@ def transition_matrix(self) -> np.ndarray:
[0. , 0.3 , 0. , 0.7 ],
[0. , 0. , 0.56, 0.44],
[0. , 0. , 0. , 1. ]])
+
"""
return matrix.generate_transition(
lnls=self.graph.lnls.values(),
@@ -417,7 +423,7 @@ def transition_matrix(self) -> np.ndarray:
def observation_matrix(self) -> np.ndarray:
- """The matrix encoding the probabilities to observe a certain diagnosis.
+ r"""Get the matrix encoding the probabilities to observe a certain diagnosis.
Every element in this matrix holds a probability to observe a certain diagnosis
(or combination of diagnosis, when using multiple diagnostic modalities) given
@@ -425,9 +431,11 @@ def observation_matrix(self) -> np.ndarray:
:math:`2^N \\times 2^\\{N \\times M\\}` where :math:`N` is the number of nodes in
the graph and :math:`M` is the number of diagnostic modalities.
- See Also:
+ See Also
+ --------
:py:func:`~lymph.descriptors.matrix.generate_observation`
The function actually computing the observation matrix.
+
"""
return matrix.generate_observation(
modalities=self.get_all_modalities().values(),
@@ -449,9 +457,11 @@ def data_matrix(self, t_stage: str | None = None) -> np.ndarray:
The data matrix is used to compute the :py:attr:`~diagnosis_matrix`, which in
turn is used to compute the likelihood of the model given the patient data.
- See Also:
+ See Also
+ --------
:py:func:`.matrix.generate_data_encoding`
This function actually computes the data encoding.
+
"""
if self._patient_data is None:
raise AttributeError("No patient data loaded yet.")
@@ -791,10 +801,12 @@ def posterior_state_dist(
In case of the Bayesian network mode, the ``t_stage`` parameter is ignored.
Warning:
+ -------
To speed up repetitive computations, one can provide precomputed state
distributions via the ``given_state_dist`` parameter. When provided, the
method will ignore the ``given_params``, ``t_stage``, and ``mode``
arguments, but compute the posterior much quicker.
+
"""
if given_state_dist is None:
# in contrast to when computing the likelihood, we do want to raise an error
@@ -805,7 +817,7 @@ def posterior_state_dist(
given_state_dist = self.state_dist(t_stage, mode=mode)
if given_diagnosis is None:
- given_diagnosis = {}
+ return given_state_dist
diagnosis_encoding = self.compute_encoding(given_diagnosis)
# vector containing P(Z=z|X). Essentially a data matrix for one patient
@@ -821,7 +833,7 @@ def posterior_state_dist(
def marginalize(
self,
- involvement: types.PatternType | None = None,
+ involvement: types.PatternType,
given_state_dist: np.ndarray | None = None,
t_stage: str = "early",
mode: Literal["HMM", "BN"] = "HMM",
@@ -835,13 +847,6 @@ def marginalize(
:py:meth:`.state_dist` with the given ``t_stage`` and ``mode``. These arguments
are ignored if ``given_state_dist`` is provided.
"""
- if (
- involvement is None
- or not involvement # empty dict is falsey
- or all(value is None for value in involvement.values())
- ):
- return 1.
-
if given_state_dist is None:
given_state_dist = self.state_dist(t_stage=t_stage, mode=mode)
@@ -855,7 +860,7 @@ def marginalize(
def risk(
self,
- involvement: types.PatternType | None = None,
+ involvement: types.PatternType,
given_params: types.ParamsType | None = None,
given_state_dist: np.ndarray | None = None,
given_diagnosis: dict[str, types.PatternType] | None = None,
@@ -952,13 +957,15 @@ def draw_patients(
A random number generator can be provided as ``rng``. If ``None``, a new one
is initialized with the given ``seed`` (or ``42``, by default).
- See Also:
+ See Also
+ --------
:py:meth:`lymph.diagnosis_times.Distribution.draw_diag_times`
Method to draw diagnosis times from a distribution.
:py:meth:`lymph.models.Unilateral.draw_diagnosis`
Method to draw individual diagnosis.
:py:meth:`lymph.models.Bilateral.draw_patients`
The corresponding bilateral method.
+
"""
if rng is None:
rng = np.random.default_rng(seed)
diff --git a/lymph/types.py b/lymph/types.py
index 6652505..065acdd 100644
--- a/lymph/types.py
+++ b/lymph/types.py
@@ -1,8 +1,8 @@
-"""
-Type aliases and protocols used in the lymph package.
+"""Type aliases and protocols used in the lymph package.
"""
from abc import ABC, abstractmethod
-from typing import Iterable, Literal, Protocol, TypeVar
+from collections.abc import Iterable
+from typing import Literal, Protocol, TypeVar
import numpy as np
import pandas as pd
@@ -14,12 +14,14 @@ class DataWarning(UserWarning):
class HasSetParams(Protocol):
"""Protocol for classes that have a ``set_params`` method."""
+
def set_params(self, *args: float, **kwargs: float) -> tuple[float]:
...
class HasGetParams(Protocol):
"""Protocol for classes that have a ``get_params`` method."""
+
def get_params(
self,
as_dict: bool = True,
@@ -89,6 +91,7 @@ class Model(ABC):
This class provides a scaffold for the methods that any model for lymphatic
tumor progression should implement.
"""
+
@abstractmethod
def get_params(
self: ModelT,
diff --git a/lymph/utils.py b/lymph/utils.py
index 4183a41..45d3d29 100644
--- a/lymph/utils.py
+++ b/lymph/utils.py
@@ -1,9 +1,9 @@
-"""
-Module containing supporting classes and functions used accross the project.
+"""Module containing supporting classes and functions used accross the project.
"""
import logging
+from collections.abc import Sequence
from functools import cached_property, lru_cache, wraps
-from typing import Any, Sequence
+from typing import Any
import numpy as np
@@ -231,6 +231,7 @@ def wrapper(self, *args, **kwargs):
class smart_updating_dict_cached_property(cached_property):
"""Allows setting/deleting dict-like attrs by updating/clearing them."""
+
def __set__(self, instance: object, value: Any) -> None:
dict_like = self.__get__(instance)
dict_like.clear()
diff --git a/pyproject.toml b/pyproject.toml
index 1cf5b6b..d8388a9 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -82,6 +82,9 @@ ensure_newline_before_comments = true
[tool.pycln]
all = true
+[tool.ruff.lint]
+select = ["E", "F", "W", "B", "C", "R", "U", "D", "I", "S", "T", "A", "N", "NPY201"]
+
# git-cliff ~ default configuration file
# https://git-cliff.org/docs/configuration
diff --git a/tests/bayesian_unilateral_test.py b/tests/bayesian_unilateral_test.py
index e1c2cc4..f73ceb5 100644
--- a/tests/bayesian_unilateral_test.py
+++ b/tests/bayesian_unilateral_test.py
@@ -1,5 +1,4 @@
-"""
-Test the Bayesian Unilateral Model.
+"""Test the Bayesian Unilateral Model.
"""
import numpy as np
diff --git a/tests/binary_bilateral_test.py b/tests/binary_bilateral_test.py
index efc198a..93c73ea 100644
--- a/tests/binary_bilateral_test.py
+++ b/tests/binary_bilateral_test.py
@@ -1,5 +1,4 @@
-"""
-Test the bilateral model.
+"""Test the bilateral model.
"""
import numpy as np
diff --git a/tests/binary_midline_test.py b/tests/binary_midline_test.py
index c65ace0..3585995 100644
--- a/tests/binary_midline_test.py
+++ b/tests/binary_midline_test.py
@@ -1,5 +1,4 @@
-"""
-Test the midline model for the binary case.
+"""Test the midline model for the binary case.
"""
import numpy as np
@@ -15,6 +14,7 @@ class MidlineSetParamsTestCase(
fixtures.IgnoreWarningsTestCase,
):
"""Check that the complex parameter assignment works correctly."""
+
def setUp(self):
return super().setUp(use_central=True, use_midext_evo=False)
diff --git a/tests/doc_test.py b/tests/doc_test.py
index f771860..e0052cc 100644
--- a/tests/doc_test.py
+++ b/tests/doc_test.py
@@ -1,6 +1,4 @@
-"""
-Make doctests in the lymph package discoverable by unittest.
-"""
+"""Make doctests in the lymph package discoverable by unittest."""
import doctest
import unittest
diff --git a/tests/emcee_intergration_test.py b/tests/emcee_intergration_test.py
index 6ccdaca..c220411 100644
--- a/tests/emcee_intergration_test.py
+++ b/tests/emcee_intergration_test.py
@@ -1,5 +1,4 @@
-"""
-Make sure the models work with the emcee package.
+"""Make sure the models work with the emcee package.
"""
import emcee
diff --git a/tests/fixtures.py b/tests/fixtures.py
index 7dfffcd..5a606f2 100644
--- a/tests/fixtures.py
+++ b/tests/fixtures.py
@@ -1,11 +1,11 @@
-"""
-Fxitures for tests.
+"""Fxitures for tests.
"""
import logging
import unittest
import warnings
+from collections.abc import Callable
from pathlib import Path
-from typing import Any, Callable, Literal
+from typing import Any, Literal
import numpy as np
import pandas as pd
@@ -170,6 +170,7 @@ def load_patient_data(
class BilateralModelMixin:
"""Mixin for testing the bilateral model."""
+
model_kwargs: dict[str, Any] | None = None
def setUp(self):
diff --git a/tests/integration_test.py b/tests/integration_test.py
index 85f36ed..bad6a2a 100644
--- a/tests/integration_test.py
+++ b/tests/integration_test.py
@@ -1,6 +1,7 @@
-"""
-Full integration test directly taken from the quickstart guide. Aimed at checking the
-computed value of the likelihood function.
+"""Full integration test.
+
+This is directly taken from the quickstart guide. Aimed at checking the computed value
+of the likelihood function.
"""
import numpy as np
diff --git a/tests/trinary_midline_test.py b/tests/trinary_midline_test.py
index 4b67821..78dcc07 100644
--- a/tests/trinary_midline_test.py
+++ b/tests/trinary_midline_test.py
@@ -1,5 +1,4 @@
-"""
-Test the midline model for the binary case.
+"""Test the midline model for the binary case.
"""
from typing import Literal
From bb9d2b48dab116b5922b68d3fd551dea086190b1 Mon Sep 17 00:00:00 2001
From: Roman Ludwig <48687784+rmnldwg@users.noreply.github.com>
Date: Tue, 25 Jun 2024 15:09:02 +0200
Subject: [PATCH 5/7] style: use ruff to fix lint and format code
---
.pre-commit-config.yaml | 15 +--
docs/source/conf.py | 34 +++----
lymph/__init__.py | 22 +++--
lymph/diagnosis_times.py | 66 ++++++-------
lymph/graph.py | 131 +++++++++++--------------
lymph/matrix.py | 37 ++++----
lymph/modalities.py | 60 ++++++------
lymph/models/__init__.py | 4 +-
lymph/models/bilateral.py | 75 ++++++---------
lymph/models/midline.py | 148 +++++++++++++++--------------
lymph/models/unilateral.py | 103 +++++++-------------
lymph/types.py | 19 +++-
lymph/utils.py | 64 +++++++------
pyproject.toml | 5 +
tests/bayesian_unilateral_test.py | 8 +-
tests/binary_bilateral_test.py | 62 +++++++-----
tests/binary_midline_test.py | 28 +++---
tests/binary_unilateral_test.py | 92 ++++++++++--------
tests/distribution_test.py | 12 +--
tests/doc_test.py | 1 +
tests/edge_test.py | 5 +-
tests/emcee_intergration_test.py | 6 +-
tests/fixtures.py | 54 +++++------
tests/graph_representation_test.py | 1 +
tests/node_test.py | 1 +
tests/trinary_midline_test.py | 6 +-
tests/trinary_unilateral_test.py | 28 ++++--
27 files changed, 528 insertions(+), 559 deletions(-)
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 638a80c..cf4761d 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -7,17 +7,12 @@ repos:
- id: check-merge-conflict
- id: check-toml
- id: check-yaml
-- repo: https://github.com/hadialqattan/pycln
- rev: v2.4.0
+- repo: https://github.com/astral-sh/ruff-pre-commit
+ rev: v0.4.10
hooks:
- - id: pycln
- args: [--config=pyproject.toml]
-- repo: https://github.com/PyCQA/isort
- rev: 5.13.2
- hooks:
- - id: isort
- files: "\\.(py)$"
- args: [--settings-path=pyproject.toml]
+ - id: ruff
+ args: [ --fix ]
+ - id: ruff-format
- repo: https://github.com/asottile/pyupgrade
rev: v3.15.2
hooks:
diff --git a/docs/source/conf.py b/docs/source/conf.py
index 80f7d1e..02980f5 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -8,10 +8,10 @@
# -- Project information -----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
-project = 'lymph'
-copyright = '2022, Roman Ludwig'
-author = 'Roman Ludwig'
-gh_username = 'rmnldwg'
+project = "lymph"
+copyright = "2022, Roman Ludwig"
+author = "Roman Ludwig"
+gh_username = "rmnldwg"
version = lymph.__version__
# The full version, including alpha/beta/rc tags
@@ -24,12 +24,12 @@
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
- 'sphinx.ext.intersphinx',
- 'sphinx.ext.autodoc',
- 'sphinx.ext.mathjax',
- 'sphinx.ext.viewcode',
- 'sphinx.ext.napoleon',
- 'myst_nb'
+ "sphinx.ext.intersphinx",
+ "sphinx.ext.autodoc",
+ "sphinx.ext.mathjax",
+ "sphinx.ext.viewcode",
+ "sphinx.ext.napoleon",
+ "myst_nb",
]
# MyST settings
@@ -38,21 +38,21 @@
nb_execution_timeout = 120
# Add any paths that contain templates here, relative to this directory.
-templates_path = ['_templates']
+templates_path = ["_templates"]
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This pattern also affects html_static_path and html_extra_path.
-exclude_patterns = ['_data']
+exclude_patterns = ["_data"]
# document classes and their constructors
-autoclass_content = 'class'
+autoclass_content = "class"
# sort members by source
-autodoc_member_order = 'bysource'
+autodoc_member_order = "bysource"
# show type hints
-autodoc_typehints = 'signature'
+autodoc_typehints = "signature"
# -- Options for HTML output -------------------------------------------------
@@ -61,7 +61,7 @@
# a list of builtin themes.
#
-html_theme = 'sphinx_book_theme'
+html_theme = "sphinx_book_theme"
html_theme_options = {
"repository_url": f"https://github.com/{gh_username}/{project}",
"repository_branch": "main",
@@ -77,7 +77,7 @@
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
-html_static_path = ['./_static']
+html_static_path = ["./_static"]
html_css_files = [
"css/custom.css",
]
diff --git a/lymph/__init__.py b/lymph/__init__.py
index 7278928..cda4c6e 100644
--- a/lymph/__init__.py
+++ b/lymph/__init__.py
@@ -1,8 +1,12 @@
+"""The lymph package implements models for lymphatic metastatic spread.
+
+It forms the basis of the first comprehensive statistical model to predict personalized
+risks for occult disease in head and neck squamous cell carcinoma patients.
+
+These models may at some point be used to inform the elective clinical target volume
+definition in radiotherapy treatment planning.
"""
-This package contains code to model the spread of microscopic metastases
-through a system of lymph node levels (LNLs), using either a Bayesian network
-or a hidden Markov model.
-"""
+
import logging
from logging import NullHandler, StreamHandler
@@ -20,9 +24,12 @@
from lymph.utils import clinical, pathological
__all__ = [
- "diagnosis_times", "matrix",
- "graph", "models",
- "clinical", "pathological",
+ "diagnosis_times",
+ "matrix",
+ "graph",
+ "models",
+ "clinical",
+ "pathological",
]
@@ -30,6 +37,7 @@
# https://github.com/urllib3/urllib3/blob/2.0.4/src/urllib3/__init__.py#L87-L107
logging.getLogger(__name__).addHandler(NullHandler())
+
def add_stderr_logging(level: int = logging.DEBUG) -> StreamHandler:
"""Add a stderr log handler to the logger."""
logger = logging.getLogger(__name__)
diff --git a/lymph/diagnosis_times.py b/lymph/diagnosis_times.py
index 58adde4..ee1aa6d 100644
--- a/lymph/diagnosis_times.py
+++ b/lymph/diagnosis_times.py
@@ -1,5 +1,4 @@
-"""
-Module for marginalizing over diagnosis times.
+"""Module for marginalizing over diagnosis times.
The hidden Markov model we implement assumes that every patient started off with a
healthy neck, meaning no lymph node levels harboured any metastases. This is a valid
@@ -11,14 +10,16 @@
same parameters, except for the parametrization of their respective distribution over
the time of diagnosis.
"""
+
from __future__ import annotations
import inspect
import logging
import warnings
from abc import ABC
+from collections.abc import Iterable
from functools import partial
-from typing import Any, Iterable, TypeVar
+from typing import Any, TypeVar
import numpy as np
@@ -34,6 +35,7 @@ class SupportError(Exception):
class Distribution:
"""Class that provides a way of storing distributions over diagnosis times."""
+
def __init__(
self,
distribution: Iterable[float] | callable,
@@ -51,6 +53,7 @@ def __init__(
function must return a list of probabilities for each diagnosis time.
Note:
+ ----
All arguments except ``support`` must have default values and if some
parameters have bounds (like the binomial distribution's ``p``), the
function must raise a ``ValueError`` if the parameter is invalid.
@@ -60,6 +63,7 @@ def __init__(
list of probabilities is passed, ``max_time`` is inferred from the length of the
list and can be omitted. But an error is raised if the length of the list and
``max_time`` + 1 don't match, in case it is accidentally provided.
+
"""
if callable(distribution):
self._init_from_callable(distribution, max_time, **kwargs)
@@ -68,7 +72,6 @@ def __init__(
else:
self._init_from_frozen(distribution, max_time)
-
def _init_from_callable(
self,
distribution: callable,
@@ -87,7 +90,6 @@ def _init_from_callable(
self._func = partial(distribution, **func_kwargs)
self._frozen = self.pmf
-
def _init_from_instance(self, instance: Distribution):
"""Initialize the distribution from another instance."""
if not instance.is_updateable:
@@ -97,8 +99,9 @@ def _init_from_instance(self, instance: Distribution):
self._func = partial(instance._func, **instance._func.keywords)
self._frozen = self.pmf
-
- def _init_from_frozen(self, distribution: Iterable[float], max_time: int | None = None):
+ def _init_from_frozen(
+ self, distribution: Iterable[float], max_time: int | None = None
+ ):
"""Initialize the distribution from a frozen distribution."""
if max_time is None:
max_time = len(distribution) - 1
@@ -113,7 +116,6 @@ def _init_from_frozen(self, distribution: Iterable[float], max_time: int | None
self._func = None
self._frozen = self.normalize(distribution)
-
@staticmethod
def extract_kwargs(distribution: callable) -> dict[str, Any]:
"""Extract the keyword arguments from the provided parametric distribution.
@@ -139,11 +141,12 @@ def extract_kwargs(distribution: callable) -> dict[str, Any]:
return kwargs
-
def __repr__(self) -> str:
+ """Return a string representation of the distribution."""
return f"Distribution({repr(self.pmf.tolist())})"
def __eq__(self, other) -> bool:
+ """Check if two distributions are equal."""
if not isinstance(other, Distribution):
return False
@@ -157,6 +160,7 @@ def __eq__(self, other) -> bool:
)
def __len__(self) -> int:
+ """Return the length of the support of the distribution."""
return len(self.support)
def __hash__(self) -> int:
@@ -169,7 +173,6 @@ def __hash__(self) -> int:
args_and_kwargs_tpl = self._func.args + tuple(self._func.keywords.items())
return hash((self.is_updateable, args_and_kwargs_tpl, self.pmf.tobytes()))
-
@property
def max_time(self) -> int:
"""Return the maximum time for the distribution."""
@@ -184,14 +187,12 @@ def max_time(self, value: int) -> None:
self.support = np.arange(value + 1)
self._frozen = None
-
@staticmethod
def normalize(distribution: np.ndarray) -> np.ndarray:
"""Normalize a distribution."""
distribution = np.array(distribution)
return distribution / np.sum(distribution)
-
@property
def pmf(self) -> np.ndarray:
"""Return the probability mass function of the distribution if it is frozen."""
@@ -199,13 +200,11 @@ def pmf(self) -> np.ndarray:
self._frozen = self.normalize(self._func(self.support))
return self._frozen
-
@property
def is_updateable(self) -> bool:
"""``True`` if instance can be updated via :py:meth:`~set_param`."""
return self._func is not None
-
def get_params(
self,
as_dict: bool = True,
@@ -213,11 +212,13 @@ def get_params(
) -> types.ParamsType:
"""If updateable, return the dist's ``param`` value or all params in a dict.
- See Also:
+ See Also
+ --------
:py:meth:`lymph.diagnosis_times.DistributionsUserDict.get_params`
:py:meth:`lymph.graph.Edge.get_params`
:py:meth:`lymph.models.Unilateral.get_params`
:py:meth:`lymph.models.Bilateral.get_params`
+
"""
if not self.is_updateable:
warnings.warn("Distribution is not updateable, returning empty dict")
@@ -225,7 +226,6 @@ def get_params(
return self._func.keywords if as_dict else self._func.keywords.values()
-
def set_params(self, *args: float, **kwargs: float) -> tuple[float]:
"""Update distribution by setting its parameters and storing the frozen PMF.
@@ -257,7 +257,6 @@ def set_params(self, *args: float, **kwargs: float) -> tuple[float]:
return args
-
def draw_diag_times(
self,
num: int | None = None,
@@ -275,9 +274,9 @@ def draw_diag_times(
return rng.choice(a=self.support, p=self.pmf, size=num)
-
DC = TypeVar("DC", bound="Composite")
+
class Composite(ABC):
"""Abstract base class implementing the composite pattern for distributions.
@@ -298,8 +297,9 @@ class Composite(ABC):
>>> leaf1.get_distribution("T1")
Distribution([0.1, 0.9])
"""
+
_max_time: int
- _distributions: dict[str, Distribution] # only for leaf nodes
+ _distributions: dict[str, Distribution] # only for leaf nodes
_distribution_children: dict[str, Composite]
def __init__(
@@ -314,12 +314,11 @@ def __init__(
if is_distribution_leaf:
self._distributions = {}
- self._distribution_children = {} # ignore any provided children
- self.max_time = max_time # only set max_time in leaf
+ self._distribution_children = {} # ignore any provided children
+ self.max_time = max_time # only set max_time in leaf
self._distribution_children = distribution_children
-
@property
def _is_distribution_leaf(self: DC) -> bool:
"""Return whether the object is a leaf node w.r.t. distributions."""
@@ -331,7 +330,6 @@ def _is_distribution_leaf(self: DC) -> bool:
return True
-
@property
def max_time(self: DC) -> int:
"""Return the maximum time for the distributions."""
@@ -344,7 +342,9 @@ def max_time(self: DC) -> int:
are_all_equal &= are_equal
if not are_all_equal:
- warnings.warn(f"Not all max_times were equal. Set all to {self._max_time}")
+ warnings.warn(
+ f"Not all max_times were equal. Set all to {self._max_time}"
+ )
return self._max_time
@@ -372,18 +372,15 @@ def max_time(self: DC, value: int) -> None:
for child in self._distribution_children.values():
child.max_time = value
-
@property
def t_stages(self: DC) -> list[str]:
"""Return the T-stages for which distributions are defined."""
return list(self.get_all_distributions().keys())
-
def get_distribution(self: DC, t_stage: str) -> Distribution:
"""Return the distribution for the given ``t_stage``."""
return self.get_all_distributions()[t_stage]
-
def get_all_distributions(self: DC) -> dict[str, Distribution]:
"""Return all distributions.
@@ -409,7 +406,6 @@ def get_all_distributions(self: DC) -> dict[str, Distribution]:
return first_distributions
-
def set_distribution(
self: DC,
t_stage: str,
@@ -423,7 +419,6 @@ def set_distribution(
for child in self._distribution_children.values():
child.set_distribution(t_stage, distribution)
-
def del_distribution(self: DC, t_stage: str) -> None:
"""Delete the distribution for the given ``t_stage``."""
if self._is_distribution_leaf:
@@ -433,8 +428,9 @@ def del_distribution(self: DC, t_stage: str) -> None:
for child in self._distribution_children.values():
child.del_distribution(t_stage)
-
- def replace_all_distributions(self: DC, distributions: dict[str, Distribution]) -> None:
+ def replace_all_distributions(
+ self: DC, distributions: dict[str, Distribution]
+ ) -> None:
"""Replace all distributions with the given ones."""
if self._is_distribution_leaf:
self._distributions = {}
@@ -445,7 +441,6 @@ def replace_all_distributions(self: DC, distributions: dict[str, Distribution])
for child in self._distribution_children.values():
child.replace_all_distributions(distributions)
-
def clear_distributions(self: DC) -> None:
"""Remove all distributions."""
if self._is_distribution_leaf:
@@ -455,7 +450,6 @@ def clear_distributions(self: DC) -> None:
for child in self._distribution_children.values():
child.clear_distributions()
-
def distributions_hash(self: DC) -> int:
"""Return a hash of all distributions."""
hash_res = 0
@@ -469,7 +463,6 @@ def distributions_hash(self: DC) -> int:
return hash_res
-
def get_distribution_params(
self: DC,
as_dict: bool = True,
@@ -498,8 +491,9 @@ def get_distribution_params(
return params if as_dict else params.values()
-
- def set_distribution_params(self: DC, *args: float, **kwargs: float) -> tuple[float]:
+ def set_distribution_params(
+ self: DC, *args: float, **kwargs: float
+ ) -> tuple[float]:
"""Set the parameters of all distributions."""
if self._is_distribution_leaf:
kwargs, global_kwargs = unflatten_and_split(
diff --git a/lymph/graph.py b/lymph/graph.py
index a21a88e..a0134ad 100644
--- a/lymph/graph.py
+++ b/lymph/graph.py
@@ -1,5 +1,4 @@
-"""
-Module defining the nodes and edges of the graph representing the lymphatic system.
+"""Module defining the nodes and edges of the graph representing the lymphatic system.
Anything related to the network of nodes and edges is defined here. This includes the
nodes themselves (either :py:class:`~Tumor` or :py:class:`~LymphNodeLevel`), the edges
@@ -9,6 +8,7 @@
accessed via the :py:class:`~Representation` class. This in turn is then used to
compute e.g. the transition matrix of the model.
"""
+
from __future__ import annotations
import base64
@@ -30,6 +30,7 @@
class AbstractNode:
"""Abstract base class for nodes in the graph reprsenting the lymphatic system."""
+
def __init__(
self,
name: str,
@@ -39,9 +40,9 @@ def __init__(
"""Make a new node.
Upon initialization, the ``name`` and ``state`` of the node must be provided.
- The ``state`` must be one of the ``allowed_states``. The constructor makes sure that
- the ``allowed_states`` are a list of ints, even when, e.g., a tuple of floats
- is provided.
+ The ``state`` must be one of the ``allowed_states``. The constructor makes sure
+ that the ``allowed_states`` are a list of ints, even when, e.g., a tuple of
+ floats is provided.
"""
self.name = name
@@ -61,7 +62,6 @@ def __init__(
# nodes can have outgoing edge connections
self.out: list[Edge] = []
-
def __str__(self) -> str:
"""Return a string representation of the node."""
return self.name
@@ -80,7 +80,6 @@ def __hash__(self) -> int:
"""Return a hash of the node's name and state."""
return hash((self.name, self.state, tuple(self.allowed_states)))
-
@property
def name(self) -> str:
"""Return the name of the node."""
@@ -91,7 +90,6 @@ def name(self, new_name: str) -> None:
"""Set the name of the node."""
self._name = str(new_name)
-
@property
def state(self) -> int:
"""Return the state of the node."""
@@ -107,7 +105,6 @@ def state(self, new_state: int) -> None:
self._state = new_state
-
def comp_obs_prob(
self,
obs: int,
@@ -121,13 +118,14 @@ def comp_obs_prob(
diagnosis the corresponding probability.
"""
if obs is None or np.isnan(obs):
- return 0 if log else 1.
+ return 0 if log else 1.0
obs_prob = obs_table[self.state, int(obs)]
return np.log(obs_prob) if log else obs_prob
class Tumor(AbstractNode):
"""A tumor in the graph representation of the lymphatic system."""
+
def __init__(self, name: str, state: int = 1) -> None:
"""Create a new tumor node.
@@ -137,14 +135,14 @@ def __init__(self, name: str, state: int = 1) -> None:
allowed_states = [state]
super().__init__(name, state, allowed_states)
-
def __str__(self):
- """Print basic info"""
+ """Print basic info."""
return f"Tumor '{super().__str__()}'"
class LymphNodeLevel(AbstractNode):
"""A lymph node level (LNL) in the graph representation of the lymphatic system."""
+
def __init__(
self,
name: str,
@@ -152,13 +150,11 @@ def __init__(
allowed_states: list[int] | None = None,
) -> None:
"""Create a new lymph node level."""
-
super().__init__(name, state, allowed_states)
# LNLs can also have incoming edge connections
self.inc: list[Edge] = []
-
@classmethod
def binary(cls, name: str, state: int = 0) -> LymphNodeLevel:
"""Create a new binary LNL."""
@@ -169,25 +165,21 @@ def trinary(cls, name: str, state: int = 0) -> LymphNodeLevel:
"""Create a new trinary LNL."""
return cls(name, state, [0, 1, 2])
-
def __str__(self):
- """Print basic info"""
+ """Print basic info."""
narity = "binary" if self.is_binary else "trinary"
return f"{narity} LNL '{super().__str__()}'"
-
@property
def is_binary(self) -> bool:
"""Return whether the node is binary."""
return len(self.allowed_states) == 2
-
@property
def is_trinary(self) -> bool:
"""Return whether the node is trinary."""
return len(self.allowed_states) == 3
-
def comp_bayes_net_prob(self, log: bool = False) -> float:
"""Compute the Bayesian network's probability for the current state."""
if self.is_trinary:
@@ -201,30 +193,34 @@ def comp_bayes_net_prob(self, log: bool = False) -> float:
res += self.state
return np.log(res) if log else res
-
def comp_trans_prob(self, new_state: int) -> float:
- """Compute the hidden Markov model's transition probability to a ``new_state``."""
+ """Compute the hidden Markov model's transition prob to a ``new_state``."""
if new_state == self.state:
- stay_prob = 1.
+ stay_prob = 1.0
for edge in self.inc:
- edge_prob = edge.transition_tensor[edge.parent.state, self.state, new_state]
+ edge_prob = edge.transition_tensor[
+ edge.parent.state, self.state, new_state
+ ]
stay_prob *= edge_prob
return stay_prob
- transition_prob = 0.
+ transition_prob = 0.0
for edge in self.inc:
edge_prob = edge.transition_tensor[edge.parent.state, self.state, new_state]
- transition_prob = 1. - (1. - transition_prob) * (1. - edge_prob)
+ transition_prob = 1.0 - (1.0 - transition_prob) * (1.0 - edge_prob)
+
+ return transition_prob
class Edge:
- """This class represents an arc in the graph representation of the lymph system."""
+ """Representation of an arc in the graph representation of the lymph system."""
+
def __init__(
self,
parent: Tumor | LymphNodeLevel,
child: LymphNodeLevel,
- spread_prob: float = 0.,
- micro_mod: float = 1.,
+ spread_prob: float = 0.0,
+ micro_mod: float = 1.0,
) -> None:
"""Create a new edge between two nodes.
@@ -247,7 +243,6 @@ def __init__(
self.spread_prob = spread_prob
-
def __str__(self) -> str:
"""Print basic info."""
return f"Edge {self.get_name(middle=' to ')}"
@@ -267,7 +262,6 @@ def __hash__(self) -> int:
"""Return a hash of the edge's transition tensor."""
return hash((self.get_name(), self.transition_tensor.tobytes()))
-
@property
def parent(self) -> Tumor | LymphNodeLevel:
"""Return the parent node that drains lymphatically via the edge."""
@@ -276,7 +270,7 @@ def parent(self) -> Tumor | LymphNodeLevel:
@parent.setter
def parent(self, new_parent: Tumor | LymphNodeLevel) -> None:
"""Set the parent node of the edge."""
- if hasattr(self, '_parent'):
+ if hasattr(self, "_parent"):
self.parent.out.remove(self)
if not issubclass(new_parent.__class__, AbstractNode):
@@ -285,7 +279,6 @@ def parent(self, new_parent: Tumor | LymphNodeLevel) -> None:
self._parent = new_parent
self.parent.out.append(self)
-
@property
def child(self) -> LymphNodeLevel:
"""Return the child node of the edge, receiving lymphatic drainage."""
@@ -294,7 +287,7 @@ def child(self) -> LymphNodeLevel:
@child.setter
def child(self, new_child: LymphNodeLevel) -> None:
"""Set the end (child) node of the edge."""
- if hasattr(self, '_child'):
+ if hasattr(self, "_child"):
self.child.inc.remove(self)
if not isinstance(new_child, LymphNodeLevel):
@@ -303,8 +296,7 @@ def child(self, new_child: LymphNodeLevel) -> None:
self._child = new_child
self.child.inc.append(self)
-
- def get_name(self, middle='to') -> str:
+ def get_name(self, middle="to") -> str:
"""Return the name of the edge.
An edge's name is simply the name of the parent node and the child node,
@@ -326,19 +318,16 @@ def get_name(self, middle='to') -> str:
return f"{self.parent.name}{middle}{self.child.name}"
-
@property
def is_growth(self) -> bool:
"""Check if this edge represents a node's growth."""
return self.parent == self.child
-
@property
def is_tumor_spread(self) -> bool:
"""Check if this edge represents spread from a tumor to an LNL."""
return isinstance(self.parent, Tumor)
-
def get_micro_mod(self) -> float:
"""Return the spread probability."""
if (
@@ -346,7 +335,7 @@ def get_micro_mod(self) -> float:
or isinstance(self.parent, Tumor)
or self.parent.is_binary
):
- self._micro_mod = 1.
+ self._micro_mod = 1.0
return self._micro_mod
def set_micro_mod(self, new_micro_mod: float | None) -> None:
@@ -357,7 +346,7 @@ def set_micro_mod(self, new_micro_mod: float | None) -> None:
if isinstance(self.parent, Tumor) or self.parent.is_binary:
warnings.warn("Microscopic spread modifier is not used for binary nodes!")
- if not 0. <= new_micro_mod <= 1.:
+ if not 0.0 <= new_micro_mod <= 1.0:
raise ValueError("Microscopic spread modifier must be between 0 and 1!")
self._micro_mod = new_micro_mod
@@ -368,11 +357,10 @@ def set_micro_mod(self, new_micro_mod: float | None) -> None:
doc="Parameter modifying spread probability in case of macroscopic involvement",
)
-
def get_spread_prob(self) -> float:
"""Return the spread probability."""
if not hasattr(self, "_spread_prob"):
- self._spread_prob = 0.
+ self._spread_prob = 0.0
return self._spread_prob
def set_spread_prob(self, new_spread_prob: float | None) -> None:
@@ -380,7 +368,7 @@ def set_spread_prob(self, new_spread_prob: float | None) -> None:
if new_spread_prob is None:
return
- if not 0. <= new_spread_prob <= 1.:
+ if not 0.0 <= new_spread_prob <= 1.0:
raise ValueError("Spread probability must be between 0 and 1!")
self._spread_prob = new_spread_prob
@@ -391,7 +379,6 @@ def set_spread_prob(self, new_spread_prob: float | None) -> None:
doc="Spread probability of the edge",
)
-
def get_params(
self,
as_dict: bool = True,
@@ -399,11 +386,13 @@ def get_params(
) -> types.ParamsType:
"""Return the value of the parameter ``param`` or all params in a dict.
- See Also:
+ See Also
+ --------
:py:meth:`lymph.diagnosis_times.Distribution.get_params`
:py:meth:`lymph.diagnosis_times.DistributionsUserDict.get_params`
:py:meth:`lymph.models.Unilateral.get_params`
:py:meth:`lymph.models.Bilateral.get_params`
+
"""
if self.is_growth:
params = {"growth": self.get_spread_prob()}
@@ -415,7 +404,6 @@ def get_params(
return params if as_dict else params.values()
-
def set_params(self, *args, **kwargs) -> tuple[float]:
"""Set the values of the edge's parameters.
@@ -427,7 +415,10 @@ def set_params(self, *args, **kwargs) -> tuple[float]:
Keyword arguments (i.e., ``"growth"``, ``"spread"``, and ``"micro"``) override
positional arguments. Unused args are returned.
- >>> edge = Edge(LymphNodeLevel("II", allowed_states=[0, 1, 2]), LymphNodeLevel("III"))
+ >>> edge = Edge(
+ ... LymphNodeLevel("II", allowed_states=[0, 1, 2]),
+ ... LymphNodeLevel("III"),
+ ... )
>>> _ = edge.set_params(0.1, 0.2)
>>> edge.spread_prob
0.1
@@ -458,13 +449,14 @@ def set_params(self, *args, **kwargs) -> tuple[float]:
return args
-
@property
def transition_tensor(self) -> np.ndarray:
"""Return the transition tensor of the edge.
- See Also:
+ See Also
+ --------
:py:func:`lymph.helper.comp_transition_tensor`
+
"""
return comp_transition_tensor(
num_parent=len(self.parent.allowed_states),
@@ -482,6 +474,7 @@ class Representation:
This class allows accessing the connected nodes (:py:class:`Tumor` and
:py:class:`LymphNodeLevel`) and edges (:py:class:`Edge`) of the :py:mod:`models`.
"""
+
def __init__(
self,
graph_dict: dict[tuple[str], list[str]],
@@ -507,7 +500,6 @@ def __init__(
self._init_nodes(graph_dict, tumor_state, allowed_states)
self._init_edges(graph_dict)
-
def _init_nodes(self, graph, tumor_state, allowed_lnl_states):
"""Initialize the nodes of the graph."""
self._nodes: dict[str, Tumor | LymphNodeLevel] = {}
@@ -526,7 +518,6 @@ def _init_nodes(self, graph, tumor_state, allowed_lnl_states):
if len(self.lnls) < 1:
raise ValueError("At least one LNL node must be present in the graph")
-
@property
def nodes(self) -> dict[str, Tumor | LymphNodeLevel]:
"""List of both :py:class:`~Tumor` and :py:class:`~LymphNodeLevel` instances."""
@@ -540,8 +531,9 @@ def tumors(self) -> dict[str, Tumor]:
@property
def lnls(self) -> dict[str, LymphNodeLevel]:
"""List of all :py:class:`~LymphNodeLevel` nodes in the graph."""
- return {n: l for n, l in self.nodes.items() if isinstance(l, LymphNodeLevel)}
-
+ return {
+ n: lnl for n, lnl in self.nodes.items() if isinstance(lnl, LymphNodeLevel)
+ }
@property
def allowed_states(self) -> list[int]:
@@ -566,7 +558,8 @@ def is_binary(self) -> bool:
def is_trinary(self) -> bool:
"""Returns ``True`` if the graph is trinary, ``False`` otherwise.
- Similar to :py:meth:`~Unilateral.is_binary`."""
+ Similar to :py:meth:`~Unilateral.is_binary`.
+ """
res = {node.is_trinary for node in self.lnls.values()}
if len(res) != 1:
@@ -574,7 +567,6 @@ def is_trinary(self) -> bool:
return res.pop()
-
def _init_edges(
self,
graph: dict[tuple[str, str], list[str]],
@@ -602,7 +594,6 @@ def _init_edges(
new_edge = Edge(parent=start, child=end)
self._edges[new_edge.get_name()] = new_edge
-
@property
def edges(self) -> dict[str, Edge]:
"""Iterable of all edges in the graph."""
@@ -636,7 +627,6 @@ def growth_edges(self) -> dict[str, Edge]:
"""
return {n: e for n, e in self.edges.items() if e.is_growth}
-
def __hash__(self) -> int:
"""Return a hash of the graph."""
hash_res = 0
@@ -645,9 +635,8 @@ def __hash__(self) -> int:
return hash_res
-
def to_dict(self) -> dict[tuple[str, str], set[str]]:
- """Returns graph representing this instance's nodes and egdes as dictionary.
+ """Return graph representing this instance's nodes and egdes as dictionary.
>>> graph_dict = {
... ('tumor', 'T'): ['II', 'III'],
@@ -662,19 +651,16 @@ def to_dict(self) -> dict[tuple[str, str], set[str]]:
for node in self.nodes.values():
node_type = "tumor" if isinstance(node, Tumor) else "lnl"
res[(node_type, node.name)] = [
- o.child.name
- for o in node.out
- if not o.is_growth
+ o.child.name for o in node.out if not o.is_growth
]
return res
-
def get_mermaid(
self,
with_params: bool = True,
direction: Literal["TD", "LR"] = "TD",
) -> str:
- """Prints the graph in mermaid format.
+ """Print the graph in mermaid format.
>>> graph_dict = {
... ('tumor', 'T'): ['II', 'III'],
@@ -707,9 +693,8 @@ def get_mermaid(
return mermaid_graph
-
def get_mermaid_url(self, **mermaid_kwargs) -> str:
- """Returns the URL to the rendered graph.
+ """Return the URL to the rendered graph.
Keyword arguments are passed to :py:meth:`~Representation.get_mermaid`.
"""
@@ -717,9 +702,7 @@ def get_mermaid_url(self, **mermaid_kwargs) -> str:
graphbytes = mermaid_graph.encode("ascii")
base64_bytes = base64.b64encode(graphbytes)
base64_string = base64_bytes.decode("ascii")
- url="https://mermaid.ink/img/" + base64_string
- return url
-
+ return "https://mermaid.ink/img/" + base64_string
def get_state(self, as_dict: bool = False) -> dict[str, int] | list[int]:
"""Return the states of the system's LNLs.
@@ -735,7 +718,6 @@ def get_state(self, as_dict: bool = False) -> dict[str, int] | list[int]:
return result if as_dict else list(result.values())
-
def set_state(self, *new_states_args, **new_states_kwargs) -> None:
"""Assign a new state to the system's LNLs.
@@ -746,7 +728,9 @@ def set_state(self, *new_states_args, **new_states_kwargs) -> None:
The keyword arguments override the positional arguments.
"""
- for new_lnl_state, lnl in zip(new_states_args, self.lnls.values()):
+ for new_lnl_state, lnl in zip(
+ new_states_args, self.lnls.values(), strict=False
+ ):
lnl.state = new_lnl_state
for key, value in new_states_kwargs.items():
@@ -754,9 +738,8 @@ def set_state(self, *new_states_args, **new_states_kwargs) -> None:
if lnl is not None and isinstance(lnl, LymphNodeLevel):
lnl.state = value
-
def _gen_state_list(self):
- """Generates the list of (hidden) states."""
+ """Generate the list of (hidden) states."""
allowed_states_list = []
for lnl in self.lnls.values():
allowed_states_list.append(lnl.allowed_states)
@@ -795,7 +778,6 @@ def state_list(self):
self._gen_state_list()
return self._state_list
-
def get_params(
self,
as_dict: bool = True,
@@ -820,7 +802,6 @@ def get_params(
return params if as_dict else params.values()
-
def set_params(self, *args, **kwargs) -> tuple[float]:
"""Set the parameters of the edges in the graph.
diff --git a/lymph/matrix.py b/lymph/matrix.py
index 9abc149..4f3376d 100644
--- a/lymph/matrix.py
+++ b/lymph/matrix.py
@@ -1,12 +1,11 @@
-"""
-Methods & classes to manage matrices of the :py:class:`~lymph.models.Unilateral` class.
-"""
+"""Module to manage matrices of the :py:class:`~lymph.models.Unilateral` class."""
+
# pylint: disable=too-few-public-methods
from __future__ import annotations
import warnings
+from collections.abc import Iterable
from functools import lru_cache
-from typing import Iterable
import numpy as np
import pandas as pd
@@ -22,7 +21,7 @@ def generate_transition(
num_states: int,
) -> np.ndarray:
"""Compute the transition matrix of the lymph model."""
- lnls = list(lnls) # necessary for `index()` call
+ lnls = list(lnls) # necessary for `index()` call
num_lnls = len(lnls)
transition_matrix = np.ones(shape=(num_states**num_lnls, num_states**num_lnls))
@@ -79,11 +78,11 @@ def generate_observation(
base: int = 2,
) -> np.ndarray:
"""Generate the observation matrix of the lymph model."""
- shape = (base ** num_lnls, 1)
+ shape = (base**num_lnls, 1)
observation_matrix = np.ones(shape=shape)
for modality in modalities:
- mod_obs_matrix = np.ones(shape=(1,1))
+ mod_obs_matrix = np.ones(shape=(1, 1))
for _ in range(num_lnls):
mod_obs_matrix = np.kron(mod_obs_matrix, modality.confusion_matrix)
@@ -143,7 +142,7 @@ def compute_encoding(
array([False, False, False, True, True, False, False, False, False])
"""
num_lnls = len(lnls)
- encoding = np.ones(shape=base ** num_lnls, dtype=bool)
+ encoding = np.ones(shape=base**num_lnls, dtype=bool)
if base == 2:
element_map = {
@@ -180,7 +179,7 @@ def compute_encoding(
encoding,
tile_and_repeat(
mat=element,
- tile=(1, base ** j),
+ tile=(1, base**j),
repeat=(1, base ** (num_lnls - j - 1)),
)[0],
)
@@ -192,7 +191,7 @@ def generate_data_encoding(
modalities: dict[str, Modality],
lnls: list[str],
) -> np.ndarray:
- """Generate the data matrix for a specific T-stage from patient data.
+ r"""Generate the data matrix for a specific T-stage from patient data.
The :py:attr:`.models.Unilateral.patient_data` needs to contain the column
``"_model"``, which is constructed when loading the data into the model. From this,
@@ -216,11 +215,11 @@ def generate_data_encoding(
diagnosis_encoding = compute_encoding(
lnls=lnls,
pattern=patient_row[modality_name],
- base=2, # observations are always binary!
+ base=2, # observations are always binary!
)
patient_encoding = np.kron(patient_encoding, diagnosis_encoding)
- result[:,i] = patient_encoding
+ result[:, i] = patient_encoding
return result.T
@@ -229,16 +228,18 @@ def generate_data_encoding(
def evolve_midext(max_time: int, midext_prob: int) -> np.ndarray:
"""Compute the evolution over the state of a tumor's midline extension."""
midext_states = np.zeros(shape=(max_time + 1, 2), dtype=float)
- midext_states[0,0] = 1.
+ midext_states[0, 0] = 1.0
- midext_transition_matrix = np.array([
- [1 - midext_prob, midext_prob],
- [0. , 1. ],
- ])
+ midext_transition_matrix = np.array(
+ [
+ [1 - midext_prob, midext_prob],
+ [0.0, 1.0],
+ ]
+ )
# compute midext prob for all time steps
for i in range(len(midext_states) - 1):
- midext_states[i+1,:] = midext_states[i,:] @ midext_transition_matrix
+ midext_states[i + 1, :] = midext_states[i, :] @ midext_transition_matrix
return midext_states
diff --git a/lymph/modalities.py b/lymph/modalities.py
index 5b91e57..f922bf7 100644
--- a/lymph/modalities.py
+++ b/lymph/modalities.py
@@ -1,11 +1,11 @@
-"""
-Module implementing management of the diagnostic modalities.
+"""Module implementing management of the diagnostic modalities.
This allows the user to define diagnostic modalities and their sensitivity/specificity
values. This is necessary to compute the likelihood of a dataset (that was created by
recoding the output of diagnostic modalities), given the model and its parameters
(which we want to learn).
"""
+
from __future__ import annotations
import warnings
@@ -17,20 +17,21 @@
class Modality:
"""Stores the confusion matrix of a diagnostic modality."""
+
def __init__(
self,
spec: float,
sens: float,
is_trinary: bool = False,
) -> None:
- if not (0. <= sens <= 1. and 0. <= spec <= 1.):
+ """Initialize the modality."""
+ if not (0.0 <= sens <= 1.0 and 0.0 <= spec <= 1.0):
raise ValueError("Senstivity and specificity must be between 0 and 1.")
self.spec = spec
self.sens = sens
self.is_trinary = is_trinary
-
def __hash__(self) -> int:
"""Return a hash of the modality.
@@ -38,15 +39,15 @@ def __hash__(self) -> int:
"""
return hash(self.confusion_matrix.tobytes())
-
def __eq__(self, other: object) -> bool:
+ """Check if two modalities are equal."""
if not isinstance(other, Modality):
return False
return np.all(self.confusion_matrix == other.confusion_matrix)
-
def __repr__(self) -> str:
+ """Return a string representation of the modality."""
return (
f"{type(self).__name__}("
f"spec={self.spec!r}, "
@@ -54,7 +55,6 @@ def __repr__(self) -> str:
f"is_trinary={self.is_trinary!r})"
)
-
@property
def spec(self) -> float:
"""Return the specificity of the modality."""
@@ -63,7 +63,7 @@ def spec(self) -> float:
@spec.setter
def spec(self, value: float) -> None:
"""Set the specificity of the modality."""
- if not 0. <= value <= 1.:
+ if not 0.0 <= value <= 1.0:
raise ValueError("Specificity must be between 0 and 1.")
if hasattr(self, "_confusion_matrix"):
@@ -71,7 +71,6 @@ def spec(self, value: float) -> None:
self._spec = value
-
@property
def sens(self) -> float:
"""Return the sensitivity of the modality."""
@@ -80,7 +79,7 @@ def sens(self) -> float:
@sens.setter
def sens(self, value: float) -> None:
"""Set the sensitivity of the modality."""
- if not 0. <= value <= 1.:
+ if not 0.0 <= value <= 1.0:
raise ValueError("Sensitivity must be between 0 and 1.")
if hasattr(self, "_confusion_matrix"):
@@ -88,18 +87,19 @@ def sens(self, value: float) -> None:
self._sens = value
-
def compute_confusion_matrix(self) -> np.ndarray:
"""Compute the confusion matrix of the modality."""
- return np.array([
- [self.spec, 1. - self.spec],
- [1. - self.sens, self.sens],
- ])
+ return np.array(
+ [
+ [self.spec, 1.0 - self.spec],
+ [1.0 - self.sens, self.sens],
+ ]
+ )
@property
def confusion_matrix(self) -> np.ndarray:
"""Return the confusion matrix of the modality."""
- if not hasattr(self, '_confusion_matrix'):
+ if not hasattr(self, "_confusion_matrix"):
self.confusion_matrix = self.compute_confusion_matrix()
if self.is_trinary and not self._confusion_matrix.shape[0] == 3:
@@ -116,13 +116,13 @@ def confusion_matrix(self, value: np.ndarray) -> None:
def check_confusion_matrix(self, value: np.ndarray) -> None:
"""Check if the confusion matrix is valid."""
row_sums = np.sum(value, axis=1)
- if not np.allclose(row_sums, 1.):
+ if not np.allclose(row_sums, 1.0):
raise ValueError("Rows of confusion matrix must sum to one.")
- if not np.all(np.greater_equal(value, 0.)):
+ if not np.all(np.greater_equal(value, 0.0)):
raise ValueError("Confusion matrix must be non-negative.")
- if not np.all(np.less_equal(value, 1.)):
+ if not np.all(np.less_equal(value, 1.0)):
raise ValueError("Confusion matrix must be less than or equal to one.")
if self.is_trinary and value.shape[0] != 3:
@@ -134,6 +134,7 @@ def check_confusion_matrix(self, value: np.ndarray) -> None:
class Clinical(Modality):
"""Stores the confusion matrix of a clinical modality."""
+
def compute_confusion_matrix(self) -> np.ndarray:
"""Compute the confusion matrix of the clinical modality."""
binary_confusion_matrix = super().compute_confusion_matrix()
@@ -145,6 +146,7 @@ def compute_confusion_matrix(self) -> np.ndarray:
class Pathological(Modality):
"""Stores the confusion matrix of a pathological modality."""
+
def compute_confusion_matrix(self) -> np.ndarray:
"""Return the confusion matrix of the pathological modality."""
binary_confusion_matrix = super().compute_confusion_matrix()
@@ -154,17 +156,18 @@ def compute_confusion_matrix(self) -> np.ndarray:
return np.vstack([binary_confusion_matrix, binary_confusion_matrix[1]])
-
MC = TypeVar("MC", bound="Composite")
+
class Composite(ABC):
"""Abstract base class implementing the composite pattern for diagnostic modalities.
Any class inheriting from this class should be able to handle the definition of
diagnostic modalities and their sensitivity/specificity values,
"""
+
_is_trinary: bool
- _modalities: dict[str, Modality] # only for leaf nodes
+ _modalities: dict[str, Modality] # only for leaf nodes
_modality_children: dict[str, Composite]
def __init__(
@@ -178,11 +181,10 @@ def __init__(
if is_modality_leaf:
self._modalities = {}
- modality_children = {} # ignore any provided children
+ modality_children = {} # ignore any provided children
self._modality_children = modality_children
-
@property
def _is_modality_leaf(self: MC) -> bool:
"""Return whether the composite is a leaf node."""
@@ -194,13 +196,11 @@ def _is_modality_leaf(self: MC) -> bool:
return True
-
@property
@abstractmethod
def is_trinary(self: MC) -> bool:
"""Return whether the modality is trinary."""
-
def modalities_hash(self: MC) -> int:
"""Compute a hash from all stored modalities.
@@ -217,12 +217,10 @@ def modalities_hash(self: MC) -> int:
return hash_res
-
def get_modality(self: MC, name: str) -> Modality:
"""Return the modality with the given ``name``."""
return self.get_all_modalities()[name]
-
def get_all_modalities(self: MC) -> dict[str, Modality]:
"""Return all modalities of the composite.
@@ -247,7 +245,6 @@ def get_all_modalities(self: MC) -> dict[str, Modality]:
return firs_modalities
-
def set_modality(
self,
name: str,
@@ -264,7 +261,6 @@ def set_modality(
for child in self._modality_children.values():
child.set_modality(name, spec, sens, kind)
-
def del_modality(self: MC, name: str) -> None:
"""Delete the modality with the given ``name``."""
if self._is_modality_leaf:
@@ -274,20 +270,20 @@ def del_modality(self: MC, name: str) -> None:
for child in self._modality_children.values():
child.del_modality(name)
-
def replace_all_modalities(self: MC, modalities: dict[str, Modality]) -> None:
"""Replace all modalities of the composite with new ``modalities``."""
if self._is_modality_leaf:
self.clear_modalities()
for name, modality in modalities.items():
- kind = "pathological" if isinstance(modality, Pathological) else "clinical"
+ kind = (
+ "pathological" if isinstance(modality, Pathological) else "clinical"
+ )
self.set_modality(name, modality.spec, modality.sens, kind)
else:
for child in self._modality_children.values():
child.replace_all_modalities(modalities)
-
def clear_modalities(self: MC) -> None:
"""Clear all modalities of the composite."""
if self._is_modality_leaf:
diff --git a/lymph/models/__init__.py b/lymph/models/__init__.py
index 6e4f916..63f560c 100644
--- a/lymph/models/__init__.py
+++ b/lymph/models/__init__.py
@@ -1,5 +1,5 @@
-"""This module implements the core classes to model lymphatic tumor progression.
-"""
+"""The lymph module implements the core classes to model lymphatic tumor progression."""
+
from lymph.models.bilateral import Bilateral
from lymph.models.midline import Midline
from lymph.models.unilateral import Unilateral
diff --git a/lymph/models/bilateral.py b/lymph/models/bilateral.py
index 8148c9a..8061ad4 100644
--- a/lymph/models/bilateral.py
+++ b/lymph/models/bilateral.py
@@ -1,3 +1,5 @@
+"""Module for bilateral lymphatic tumor progression models."""
+
from __future__ import annotations
import logging
@@ -95,7 +97,6 @@ def __init__(
is_modality_leaf=False,
)
-
def _init_models(
self,
graph_dict: dict[tuple[str], list[str]],
@@ -115,10 +116,9 @@ def _init_models(
_contra_kwargs["graph_dict"] = graph_dict
_contra_kwargs.update(contra_kwargs or {})
- self.ipsi = models.Unilateral(**_ipsi_kwargs)
+ self.ipsi = models.Unilateral(**_ipsi_kwargs)
self.contra = models.Unilateral(**_contra_kwargs)
-
@classmethod
def binary(cls, *args, **kwargs) -> Bilateral:
"""Initialize a binary bilateral model.
@@ -143,7 +143,6 @@ def trinary(cls, *args, **kwargs) -> Bilateral:
uni_kwargs["allowed_states"] = [0, 1, 2]
return cls(*args, uni_kwargs=uni_kwargs, **kwargs)
-
@property
def is_trinary(self) -> bool:
"""Return whether the model is trinary."""
@@ -160,7 +159,6 @@ def is_binary(self) -> bool:
return self.ipsi.is_binary
-
def get_tumor_spread_params(
self,
as_dict: bool = True,
@@ -192,7 +190,6 @@ def get_tumor_spread_params(
return params if as_dict else params.values()
-
def get_lnl_spread_params(
self,
as_dict: bool = True,
@@ -224,7 +221,6 @@ def get_lnl_spread_params(
return params if as_dict else params.values()
-
def get_spread_params(
self,
as_dict: bool = True,
@@ -253,7 +249,9 @@ def get_spread_params(
>>> num_dims = model.get_num_dims()
>>> num_dims
5
- >>> model.set_spread_params(*np.round(np.linspace(0., 1., num_dims+1), 2)) # doctest: +SKIP
+ >>> model.set_spread_params(
+ ... *np.round(np.linspace(0., 1., num_dims+1), 2),
+ ... ) # doctest: +SKIP
(1.0,)
>>> model.get_spread_params(as_flat=False) # doctest: +SKIP
{'ipsi': {'TtoII': {'spread': 0.0},
@@ -270,7 +268,10 @@ def get_spread_params(
"""
params = self.get_tumor_spread_params(as_flat=False)
- if not self.is_symmetric["tumor_spread"] and not self.is_symmetric["lnl_spread"]:
+ if (
+ not self.is_symmetric["tumor_spread"]
+ and not self.is_symmetric["lnl_spread"]
+ ):
params["ipsi"].update(self.get_lnl_spread_params(as_flat=False)["ipsi"])
params["contra"].update(self.get_lnl_spread_params(as_flat=False)["contra"])
else:
@@ -281,7 +282,6 @@ def get_spread_params(
return params if as_dict else params.values()
-
def get_params(
self,
as_dict: bool = True,
@@ -305,10 +305,11 @@ def get_params(
return params if as_dict else params.values()
-
def set_tumor_spread_params(self, *args: float, **kwargs: float) -> tuple[float]:
"""Set the parameters of the model's spread from tumor to LNLs."""
- kwargs, global_kwargs = utils.unflatten_and_split(kwargs, expected_keys=["ipsi", "contra"])
+ kwargs, global_kwargs = utils.unflatten_and_split(
+ kwargs, expected_keys=["ipsi", "contra"]
+ )
ipsi_kwargs = global_kwargs.copy()
ipsi_kwargs.update(kwargs.get("ipsi", {}))
@@ -326,11 +327,11 @@ def set_tumor_spread_params(self, *args: float, **kwargs: float) -> tuple[float]
return args
-
def set_lnl_spread_params(self, *args: float, **kwargs: float) -> tuple[float]:
"""Set the parameters of the model's spread from LNLs to tumor."""
kwargs, global_kwargs = utils.unflatten_and_split(
- kwargs, expected_keys=["ipsi", "contra"],
+ kwargs,
+ expected_keys=["ipsi", "contra"],
)
ipsi_kwargs = global_kwargs.copy()
@@ -349,13 +350,11 @@ def set_lnl_spread_params(self, *args: float, **kwargs: float) -> tuple[float]:
return args
-
def set_spread_params(self, *args: float, **kwargs: float) -> tuple[float]:
"""Set the parameters of the model's spread edges."""
args = self.set_tumor_spread_params(*args, **kwargs)
return self.set_lnl_spread_params(*args, **kwargs)
-
def set_params(self, *args: float, **kwargs: float) -> tuple[float]:
"""Set new parameters to the model.
@@ -393,7 +392,6 @@ def set_params(self, *args: float, **kwargs: float) -> tuple[float]:
args = self.set_spread_params(*args, **kwargs)
return self.set_distribution_params(*args, **kwargs)
-
def load_patient_data(
self,
patient_data: pd.DataFrame,
@@ -401,13 +399,12 @@ def load_patient_data(
) -> None:
"""Load patient data into the model.
- This amounts to calling the :py:meth:`~lymph.models.Unilateral.load_patient_data`
+ Amounts to calling the :py:meth:`~lymph.models.Unilateral.load_patient_data`
method of both sides of the neck.
"""
self.ipsi.load_patient_data(patient_data, "ipsi", mapping)
self.contra.load_patient_data(patient_data, "contra", mapping)
-
def state_dist(
self,
t_stage: str = "early",
@@ -444,7 +441,6 @@ def state_dist(
return result
-
def obs_dist(
self,
given_state_dist: np.ndarray | None = None,
@@ -470,7 +466,6 @@ def obs_dist(
@ self.contra.observation_matrix()
)
-
def patient_likelihoods(
self,
t_stage: str,
@@ -483,7 +478,6 @@ def patient_likelihoods(
joint_state_dist @ self.contra.diagnosis_matrix(t_stage).T,
)
-
def _bn_likelihood(self, log: bool = True, t_stage: str | None = None) -> float:
"""Compute the BN likelihood of data, using the stored params."""
joint_state_dist = self.state_dist(mode="BN")
@@ -494,10 +488,9 @@ def _bn_likelihood(self, log: bool = True, t_stage: str | None = None) -> float:
return np.sum(np.log(patient_llhs)) if log else np.prod(patient_llhs)
-
def _hmm_likelihood(self, log: bool = True, t_stage: str | None = None) -> float:
"""Compute the HMM likelihood of data, using the stored params."""
- llh = 0. if log else 1.
+ llh = 0.0 if log else 1.0
ipsi_dist_evo = self.ipsi.state_dist_evo()
contra_dist_evo = self.contra.state_dist_evo()
@@ -512,11 +505,7 @@ def _hmm_likelihood(self, log: bool = True, t_stage: str | None = None) -> float
# Note that I am not using the `comp_joint_state_dist` method here, since
# that would recompute the state dist evolution for each T-stage.
- joint_state_dist = (
- ipsi_dist_evo.T
- @ diag_time_matrix
- @ contra_dist_evo
- )
+ joint_state_dist = ipsi_dist_evo.T @ diag_time_matrix @ contra_dist_evo
patient_llhs = matrix.fast_trace(
self.ipsi.diagnosis_matrix(stage),
joint_state_dist @ self.contra.diagnosis_matrix(stage).T,
@@ -525,7 +514,6 @@ def _hmm_likelihood(self, log: bool = True, t_stage: str | None = None) -> float
return llh
-
def likelihood(
self,
given_params: types.ParamsType | None = None,
@@ -558,7 +546,7 @@ def likelihood(
# given parameters are invalid...
utils.safe_set_params(self, given_params)
except ValueError:
- return -np.inf if log else 0.
+ return -np.inf if log else 0.0
if mode == "HMM":
return self._hmm_likelihood(log, t_stage)
@@ -567,7 +555,6 @@ def likelihood(
raise ValueError("Invalid mode. Must be either 'HMM' or 'BN'.")
-
def posterior_state_dist(
self,
given_params: types.ParamsType | None = None,
@@ -578,7 +565,7 @@ def posterior_state_dist(
) -> np.ndarray:
"""Compute joint post. dist. over ipsi & contra states, ``given_diagnosis``.
- The ``given_diagnosis`` is a dictionary storing one :py:obj:`.types.DiagnosisType`
+ ``given_diagnosis`` is a dictionary storing one :py:obj:`.types.DiagnosisType`
each for the ``"ipsi"`` and ``"contra"`` side of the neck.
Essentially, this is the risk for any possible combination of ipsi- and
@@ -587,7 +574,7 @@ def posterior_state_dist(
Warning:
-------
As in the :py:meth:`.Unilateral.posterior_state_dist` method, one may
- provide a precomputed (joint) state distribution via the ``given_state_dist``
+ provide a precomputed (joint) state dist via the ``given_state_dist``
argument (should be a square matrix). In this case, the ``given_params``
are ignored and the model does not need to recompute e.g. the
:py:meth:`.transition_matrix` or :py:meth:`.state_dist`, making the
@@ -617,15 +604,17 @@ def posterior_state_dist(
diagnosis_given_state[side] = diagnosis_encoding @ observation_matrix.T
# matrix with P(Zi=zi,Zc=zc|Xi,Xc) * P(Xi,Xc) for all states Xi,Xc.
- joint_diagnosis_and_state = np.outer(
- diagnosis_given_state["ipsi"],
- diagnosis_given_state["contra"],
- ) * given_state_dist
+ joint_diagnosis_and_state = (
+ np.outer(
+ diagnosis_given_state["ipsi"],
+ diagnosis_given_state["contra"],
+ )
+ * given_state_dist
+ )
# Following Bayes' theorem, this is P(Xi,Xc|Zi=zi,Zc=zc) which is given by
# P(Zi=zi,Zc=zc|Xi,Xc) * P(Xi,Xc) / P(Zi=zi,Zc=zc)
return joint_diagnosis_and_state / np.sum(joint_diagnosis_and_state)
-
def marginalize(
self,
involvement: dict[str, types.PatternType],
@@ -659,7 +648,6 @@ def marginalize(
@ marginalize_over_states["contra"]
)
-
def risk(
self,
involvement: dict[str, types.PatternType],
@@ -691,7 +679,6 @@ def risk(
return self.marginalize(involvement, posterior_state_dist)
-
def draw_patients(
self,
num: int,
@@ -715,7 +702,7 @@ def draw_patients(
if rng is None:
rng = np.random.default_rng(seed)
- if sum(stage_dist) != 1.:
+ if sum(stage_dist) != 1.0:
warnings.warn("Sum of stage distribution is not 1. Renormalizing.")
stage_dist = np.array(stage_dist) / sum(stage_dist)
@@ -737,7 +724,7 @@ def draw_patients(
# concatenation of the two separate drawn diagnosis
sides = ["ipsi", "contra"]
modality_names = list(self.get_all_modalities().keys())
- lnl_names = [lnl for lnl in self.ipsi.graph.lnls.keys()]
+ lnl_names = list(self.ipsi.graph.lnls.keys())
multi_cols = pd.MultiIndex.from_product([sides, modality_names, lnl_names])
# reorder the column levels and thus also the individual columns to match the
@@ -745,6 +732,6 @@ def draw_patients(
dataset = pd.DataFrame(drawn_obs, columns=multi_cols)
dataset = dataset.reorder_levels(order=[1, 0, 2], axis="columns")
dataset = dataset.sort_index(axis="columns", level=0)
- dataset[('tumor', '1', 't_stage')] = drawn_t_stages
+ dataset[("tumor", "1", "t_stage")] = drawn_t_stages
return dataset
diff --git a/lymph/models/midline.py b/lymph/models/midline.py
index 2021a89..e43414d 100644
--- a/lymph/models/midline.py
+++ b/lymph/models/midline.py
@@ -1,3 +1,5 @@
+"""Module for modeling metastatic progression bilaterally with tumor lateralization."""
+
from __future__ import annotations
import logging
@@ -18,7 +20,6 @@
CENTRAL_COL = ("tumor", "1", "central")
-
class Midline(
diagnosis_times.Composite,
modalities.Composite,
@@ -56,7 +57,7 @@ def __init__(
use_midext_evo: bool = True,
marginalize_unknown: bool = True,
uni_kwargs: dict[str, Any] | None = None,
- **_kwargs
+ **_kwargs,
):
"""Initialize the model.
@@ -148,13 +149,17 @@ def __init__(
other_children["unknown"] = self.unknown
if use_mixing:
- self.mixing_param = 0.
+ self.mixing_param = 0.0
- self.midext_prob = 0.
+ self.midext_prob = 0.0
diagnosis_times.Composite.__init__(
self,
- distribution_children={"ext": self.ext, "noext": self.noext, **other_children},
+ distribution_children={
+ "ext": self.ext,
+ "noext": self.noext,
+ **other_children,
+ },
is_distribution_leaf=False,
)
modalities.Composite.__init__(
@@ -163,7 +168,6 @@ def __init__(
is_modality_leaf=False,
)
-
@classmethod
def trinary(cls, *args, **kwargs) -> Midline:
"""Create a trinary model."""
@@ -171,7 +175,6 @@ def trinary(cls, *args, **kwargs) -> Midline:
uni_kwargs["allowed_states"] = [0, 1, 2]
return cls(*args, uni_kwargs=uni_kwargs, **kwargs)
-
@property
def is_trinary(self) -> bool:
"""Return whether the model is trinary."""
@@ -183,22 +186,20 @@ def is_trinary(self) -> bool:
return self.ext.is_trinary
-
@property
def midext_prob(self) -> float:
"""Return the probability of midline extension."""
if hasattr(self, "_midext_prob"):
return self._midext_prob
- return 0.
+ return 0.0
@midext_prob.setter
def midext_prob(self, value: float) -> None:
"""Set the probability of midline extension."""
- if value is not None and not 0. <= value <= 1.:
+ if value is not None and not 0.0 <= value <= 1.0:
raise ValueError("The midline extension prob must be in the range [0, 1].")
self._midext_prob = value
-
@property
def mixing_param(self) -> float | None:
"""Return the mixing parameter."""
@@ -209,7 +210,7 @@ def mixing_param(self) -> float | None:
@mixing_param.setter
def mixing_param(self, value: float) -> None:
"""Set the mixing parameter."""
- if value is not None and not 0. <= value <= 1.:
+ if value is not None and not 0.0 <= value <= 1.0:
raise ValueError("The mixing parameter must be in the range [0, 1].")
self._mixing_param = value
@@ -244,7 +245,6 @@ def unknown(self) -> models.Bilateral:
"This instance does not marginalize over unknown midline extension."
)
-
def get_tumor_spread_params(
self,
as_dict: bool = True,
@@ -261,7 +261,9 @@ def get_tumor_spread_params(
params["ipsi"] = self.ext.ipsi.get_tumor_spread_params(as_flat=as_flat)
if self.use_mixing:
- params["contra"] = self.noext.contra.get_tumor_spread_params(as_flat=as_flat)
+ params["contra"] = self.noext.contra.get_tumor_spread_params(
+ as_flat=as_flat
+ )
params["mixing"] = self.mixing_param
else:
params["noext"] = {
@@ -276,7 +278,6 @@ def get_tumor_spread_params(
return params if as_dict else params.values()
-
def get_lnl_spread_params(
self,
as_dict: bool = True,
@@ -310,7 +311,6 @@ def get_lnl_spread_params(
return ext_lnl_params if as_dict else ext_lnl_params.values()
-
def get_spread_params(
self,
as_dict: bool = True,
@@ -337,7 +337,6 @@ def get_spread_params(
return params if as_dict else params.values()
-
def get_params(
self,
as_dict: bool = True,
@@ -345,7 +344,7 @@ def get_params(
) -> types.ParamsType:
"""Return all the parameters of the model.
- This includes the spread parameters from the call to :py:meth:`get_spread_params`
+ Includes the spread parameters from the call to :py:meth:`get_spread_params`
and the distribution parameters from the call to
:py:meth:`~.diagnosis_times.Composite.get_distribution_params`.
"""
@@ -359,9 +358,10 @@ def get_params(
return params if as_dict else params.values()
-
def set_tumor_spread_params(
- self, *args: float, **kwargs: float,
+ self,
+ *args: float,
+ **kwargs: float,
) -> types.ParamsType:
"""Set the spread parameters of the midline model.
@@ -373,7 +373,8 @@ def set_tumor_spread_params(
can provide.
"""
kwargs, global_kwargs = utils.unflatten_and_split(
- kwargs, expected_keys=["ipsi", "noext", "ext", "contra"],
+ kwargs,
+ expected_keys=["ipsi", "noext", "ext", "contra"],
)
# first, take care of ipsilateral tumor spread (same for all models)
@@ -390,7 +391,9 @@ def set_tumor_spread_params(
contra_kwargs.update(kwargs.get("contra", {}))
args = self.noext.contra.set_tumor_spread_params(*args, **contra_kwargs)
mixing_param, args = utils.popfirst(args)
- mixing_param = global_kwargs.get("mixing", mixing_param) or self.mixing_param
+ mixing_param = (
+ global_kwargs.get("mixing", mixing_param) or self.mixing_param
+ )
self.mixing_param = global_kwargs.get("mixing", mixing_param)
ext_contra_kwargs = {}
@@ -401,14 +404,16 @@ def set_tumor_spread_params(
):
ext_contra_kwargs[key] = (
self.mixing_param * ipsi_param
- + (1. - self.mixing_param) * noext_contra_param
+ + (1.0 - self.mixing_param) * noext_contra_param
)
self.ext.contra.set_tumor_spread_params(**ext_contra_kwargs)
else:
noext_contra_kwargs = global_kwargs.copy()
noext_contra_kwargs.update(kwargs.get("noext", {}).get("contra", {}))
- args = self.noext.contra.set_tumor_spread_params(*args, **noext_contra_kwargs)
+ args = self.noext.contra.set_tumor_spread_params(
+ *args, **noext_contra_kwargs
+ )
ext_contra_kwargs = global_kwargs.copy()
ext_contra_kwargs.update(kwargs.get("ext", {}).get("contra", {}))
@@ -416,7 +421,6 @@ def set_tumor_spread_params(
return args
-
def set_lnl_spread_params(self, *args: float, **kwargs: float) -> Iterable[float]:
"""Set the LNL spread parameters of the midline model.
@@ -426,7 +430,8 @@ def set_lnl_spread_params(self, *args: float, **kwargs: float) -> Iterable[float
``use_central`` attribute.
"""
kwargs, global_kwargs = utils.unflatten_and_split(
- kwargs, expected_keys=["ipsi", "noext", "ext", "contra"],
+ kwargs,
+ expected_keys=["ipsi", "noext", "ext", "contra"],
)
ipsi_kwargs = global_kwargs.copy()
ipsi_kwargs.update(kwargs.get("ipsi", {}))
@@ -455,15 +460,15 @@ def set_lnl_spread_params(self, *args: float, **kwargs: float) -> Iterable[float
return args
-
def set_spread_params(self, *args: float, **kwargs: float) -> Iterable[float]:
"""Set the spread parameters of the midline model."""
args = self.set_tumor_spread_params(*args, **kwargs)
return self.set_lnl_spread_params(*args, **kwargs)
-
def set_params(
- self, *args: float, **kwargs: float,
+ self,
+ *args: float,
+ **kwargs: float,
) -> types.ParamsType:
"""Set all parameters of the model.
@@ -475,7 +480,6 @@ def set_params(
args = self.set_spread_params(*args, **kwargs)
return self.set_distribution_params(*args, **kwargs)
-
def load_patient_data(
self,
patient_data: pd.DataFrame,
@@ -485,7 +489,7 @@ def load_patient_data(
This amounts to sorting the patients into three bins:
- 1. Patients whose tumor is clearly laterlaized, meaning the column
+ 1. Patients whose tumor is clearly lateralized, meaning the column
``("tumor", "1", "extension")`` reports ``False``. These get assigned to
the :py:attr:`.noext` attribute.
2. Those with a central tumor, indicated by ``True`` in the column
@@ -500,13 +504,13 @@ def load_patient_data(
the respective models.
"""
# pylint: disable=singleton-comparison
- is_lateralized = patient_data[EXT_COL] == False
- has_extension = patient_data[EXT_COL] == True
+ is_lateralized = patient_data[EXT_COL] == False # noqa: E712
+ has_extension = patient_data[EXT_COL] == True # noqa: E712
is_unknown = patient_data[EXT_COL].isna()
self.noext.load_patient_data(patient_data[is_lateralized], mapping)
if self.use_central:
- is_central = patient_data[CENTRAL_COL] == True
+ is_central = patient_data[CENTRAL_COL] == True # noqa: E712
has_extension = has_extension & ~is_central
self.central.load_patient_data(patient_data[is_central], mapping)
@@ -520,16 +524,14 @@ def load_patient_data(
"is unknown."
)
-
def midext_evo(self) -> np.ndarray:
"""Evolve only the state of the midline extension."""
time_steps = np.arange(self.max_time + 1)
midext_states = np.zeros(shape=(self.max_time + 1, 2), dtype=float)
- midext_states[:,0] = (1. - self.midext_prob) ** time_steps
- midext_states[:,1] = 1. - midext_states[:,0]
+ midext_states[:, 0] = (1.0 - self.midext_prob) ** time_steps
+ midext_states[:, 1] = 1.0 - midext_states[:, 0]
return midext_states
-
def contra_state_dist_evo(self) -> tuple[np.ndarray, np.ndarray]:
"""Evolve contra side as mixture of with & without midline extension.
@@ -553,7 +555,7 @@ def contra_state_dist_evo(self) -> tuple[np.ndarray, np.ndarray]:
if not self.use_midext_evo:
ext_contra_dist_evo = self.ext.contra.state_dist_evo()
- noext_contra_dist_evo *= (1. - self.midext_prob)
+ noext_contra_dist_evo *= 1.0 - self.midext_prob
ext_contra_dist_evo *= self.midext_prob
else:
@@ -564,7 +566,7 @@ def contra_state_dist_evo(self) -> tuple[np.ndarray, np.ndarray]:
# multiplied with the probabilities of having no midline extension at all
# time steps, resulting in a vector of length `max_time + 1`:
# P(X_c[t] | noext) * P(noext | t)
- noext_contra_dist_evo *= midext_evo[:,0].reshape(-1, 1)
+ noext_contra_dist_evo *= midext_evo[:, 0].reshape(-1, 1)
for t in range(self.max_time):
# For the case of midline extension, we need to consider all possible
@@ -579,11 +581,10 @@ def contra_state_dist_evo(self) -> tuple[np.ndarray, np.ndarray]:
# use the ext state. The probability of "keeping" the midline
# extension is 1, so we don't need to multiply it with anything.
+ ext_contra_dist_evo[t]
- ) @ self.ext.contra.transition_matrix() # then evolve using ext model
+ ) @ self.ext.contra.transition_matrix() # then evolve using ext model
return noext_contra_dist_evo, ext_contra_dist_evo
-
def state_dist(
self,
t_stage: str = "early",
@@ -614,7 +615,6 @@ def state_dist(
raise NotImplementedError("Only HMM mode is supported as of now.")
-
def obs_dist(
self,
given_state_dist: np.ndarray | None = None,
@@ -637,7 +637,9 @@ def obs_dist(
"""
if given_state_dist is None:
- given_state_dist = self.state_dist(t_stage=t_stage, mode=mode, central=central)
+ given_state_dist = self.state_dist(
+ t_stage=t_stage, mode=mode, central=central
+ )
if given_state_dist.ndim == 2:
return self.ext.obs_dist(given_state_dist=given_state_dist)
@@ -650,10 +652,11 @@ def obs_dist(
]
return np.stack(obs_dist)
-
- def _hmm_likelihood(self, log: bool = True, for_t_stage: str | None = None) -> float:
+ def _hmm_likelihood(
+ self, log: bool = True, for_t_stage: str | None = None
+ ) -> float:
"""Compute the likelihood of the stored data under the hidden Markov model."""
- llh = 0. if log else 1.
+ llh = 0.0 if log else 1.0
ipsi_dist_evo = self.ext.ipsi.state_dist_evo()
contra_dist_evo = {}
@@ -667,22 +670,21 @@ def _hmm_likelihood(self, log: bool = True, for_t_stage: str | None = None) -> f
# see the `Bilateral` model for why this is done in this way.
for case in ["ext", "noext"]:
joint_state_dist = (
- ipsi_dist_evo.T
- @ diag_time_matrix
- @ contra_dist_evo[case]
+ ipsi_dist_evo.T @ diag_time_matrix @ contra_dist_evo[case]
)
marg_joint_state_dist += joint_state_dist
_model = getattr(self, case)
patient_llhs = matrix.fast_trace(
_model.ipsi.diagnosis_matrix(stage),
- joint_state_dist @ _model.contra.diagnosis_matrix(stage).T
+ joint_state_dist @ _model.contra.diagnosis_matrix(stage).T,
)
llh = utils.add_or_mult(llh, patient_llhs, log=log)
try:
marg_patient_llhs = matrix.fast_trace(
self.unknown.ipsi.diagnosis_matrix(stage),
- marg_joint_state_dist @ self.unknown.contra.diagnosis_matrix(stage).T
+ marg_joint_state_dist
+ @ self.unknown.contra.diagnosis_matrix(stage).T,
)
llh = utils.add_or_mult(llh, marg_patient_llhs, log=log)
except AttributeError:
@@ -698,7 +700,6 @@ def _hmm_likelihood(self, log: bool = True, for_t_stage: str | None = None) -> f
return llh
-
def likelihood(
self,
given_params: types.ParamsType | None = None,
@@ -731,14 +732,13 @@ def likelihood(
# given parameters are invalid...
utils.safe_set_params(self, given_params)
except ValueError:
- return -np.inf if log else 0.
+ return -np.inf if log else 0.0
if mode == "HMM":
return self._hmm_likelihood(log, t_stage)
raise NotImplementedError("Only HMM mode is supported as of now.")
-
def posterior_state_dist(
self,
given_params: types.ParamsType | None = None,
@@ -770,7 +770,9 @@ def posterior_state_dist(
# is the only thing that could differ between models.
if given_state_dist is None:
utils.safe_set_params(self, given_params)
- given_state_dist = self.state_dist(t_stage=t_stage, mode=mode, central=central)
+ given_state_dist = self.state_dist(
+ t_stage=t_stage, mode=mode, central=central
+ )
if given_state_dist.ndim == 2:
return self.ext.posterior_state_dist(
@@ -792,7 +794,6 @@ def posterior_state_dist(
given_diagnosis=given_diagnosis,
)
-
def marginalize(
self,
involvement: dict[str, types.PatternType] | None = None,
@@ -815,7 +816,9 @@ def marginalize(
involvement = {}
if given_state_dist is None:
- given_state_dist = self.state_dist(t_stage=t_stage, mode=mode, central=central)
+ given_state_dist = self.state_dist(
+ t_stage=t_stage, mode=mode, central=central
+ )
if given_state_dist.ndim == 2:
return self.ext.marginalize(
@@ -836,7 +839,6 @@ def marginalize(
given_state_dist=given_state_dist,
)
-
def risk(
self,
involvement: types.PatternType | None = None,
@@ -888,7 +890,6 @@ def risk(
midext=midext,
)
-
def draw_patients(
self,
num: int,
@@ -900,7 +901,7 @@ def draw_patients(
if rng is None:
rng = np.random.default_rng(seed)
- if sum(stage_dist) != 1.:
+ if sum(stage_dist) != 1.0:
warnings.warn("Sum of stage distribution is not 1. Renormalizing.")
stage_dist = np.array(stage_dist) / sum(stage_dist)
@@ -915,21 +916,22 @@ def draw_patients(
size=num,
)
distributions = self.get_all_distributions()
- drawn_diag_times = np.array([
- distributions[t_stage].draw_diag_times(rng=rng)
- for t_stage in drawn_t_stages
- ])
+ drawn_diag_times = np.array(
+ [
+ distributions[t_stage].draw_diag_times(rng=rng)
+ for t_stage in drawn_t_stages
+ ]
+ )
if self.use_midext_evo:
midext_evo = self.midext_evo()
- drawn_midexts = np.array([
- rng.choice(a=[False, True], p=midext_evo[t])
- for t in drawn_diag_times
- ])
+ drawn_midexts = np.array(
+ [rng.choice(a=[False, True], p=midext_evo[t]) for t in drawn_diag_times]
+ )
else:
drawn_midexts = rng.choice(
a=[False, True],
- p=[1. - self.midext_prob, self.midext_prob],
+ p=[1.0 - self.midext_prob, self.midext_prob],
size=num,
)
@@ -953,14 +955,16 @@ def draw_patients(
rng=rng,
seed=seed,
)
- drawn_case_diags = np.concatenate([drawn_ipsi_diags, drawn_contra_diags], axis=1)
+ drawn_case_diags = np.concatenate(
+ [drawn_ipsi_diags, drawn_contra_diags], axis=1
+ )
drawn_diags[drawn_midexts == (case == "ext")] = drawn_case_diags
# construct MultiIndex with "ipsi" and "contra" at top level to allow
# concatenation of the two separate drawn diagnosis
sides = ["ipsi", "contra"]
modality_names = list(self.get_all_modalities().keys())
- lnl_names = [lnl for lnl in self.ext.ipsi.graph.lnls.keys()]
+ lnl_names = list(self.ext.ipsi.graph.lnls.keys())
multi_cols = pd.MultiIndex.from_product([sides, modality_names, lnl_names])
# reorder the column levels and thus also the individual columns to match the
diff --git a/lymph/models/unilateral.py b/lymph/models/unilateral.py
index f92f226..0755f53 100644
--- a/lymph/models/unilateral.py
+++ b/lymph/models/unilateral.py
@@ -1,3 +1,5 @@
+"""Base module for the lymphatic system models."""
+
from __future__ import annotations
import warnings
@@ -12,10 +14,10 @@
from lymph import diagnosis_times, graph, matrix, modalities, types, utils
# pylint: disable=unused-import
-from lymph.utils import dict_to_func # noqa: F401
-from lymph.utils import draw_diagnosis # noqa: F401
from lymph.utils import ( # nopycln: import
add_or_mult,
+ dict_to_func, # noqa: F401
+ draw_diagnosis, # noqa: F401
early_late_mapping,
flatten,
get_params_from,
@@ -101,26 +103,25 @@ def __init__(
allowed_states=allowed_states,
)
- diagnosis_times.Composite.__init__(self, max_time=max_time, is_distribution_leaf=True)
+ diagnosis_times.Composite.__init__(
+ self, max_time=max_time, is_distribution_leaf=True
+ )
modalities.Composite.__init__(self, is_modality_leaf=True)
self._patient_data: pd.DataFrame | None = None
self._cache_version: int = 0
self._data_matrix_cache: LRUCache = LRUCache(maxsize=64)
self._diagnosis_matrix_cache: LRUCache = LRUCache(maxsize=64)
-
@classmethod
def binary(cls, graph_dict: types.GraphDictType, **kwargs) -> Unilateral:
"""Create an instance of the :py:class:`~Unilateral` class with binary LNLs."""
return cls(graph_dict, allowed_states=[0, 1], **kwargs)
-
@classmethod
def trinary(cls, graph_dict: types.GraphDictType, **kwargs) -> Unilateral:
"""Create an instance of the :py:class:`~Unilateral` class with trinary LNLs."""
return cls(graph_dict, allowed_states=[0, 1, 2], **kwargs)
-
def __repr__(self) -> str:
"""Return a string representation of the instance."""
return (
@@ -131,11 +132,12 @@ def __repr__(self) -> str:
f"max_time={self.max_time})"
)
-
def __str__(self) -> str:
"""Print info about the instance."""
- return f"Unilateral with {len(self.graph.tumors)} tumors and {len(self.graph.lnls)} LNLs"
-
+ return (
+ f"Unilateral with {len(self.graph.tumors)} tumors "
+ f"and {len(self.graph.lnls)} LNLs"
+ )
@property
def is_trinary(self) -> bool:
@@ -147,7 +149,6 @@ def is_binary(self) -> bool:
"""Return whether the model is binary."""
return self.graph.is_binary
-
def get_t_stages(
self,
which: Literal["valid", "distributions", "data"] = "valid",
@@ -175,7 +176,6 @@ def get_t_stages(
"'distributions', or 'data'."
)
-
def get_tumor_spread_params(
self,
as_dict: bool = True,
@@ -184,7 +184,6 @@ def get_tumor_spread_params(
"""Get the parameters of the tumor spread edges."""
return get_params_from(self.graph.tumor_edges, as_dict, as_flat)
-
def get_lnl_spread_params(
self,
as_dict: bool = True,
@@ -197,7 +196,6 @@ def get_lnl_spread_params(
"""
return get_params_from(self.graph.lnl_edges, as_dict, as_flat)
-
def get_spread_params(
self,
as_dict: bool = True,
@@ -212,7 +210,6 @@ def get_spread_params(
return params if as_dict else params.values()
-
def get_params(
self,
as_dict: bool = True,
@@ -232,23 +229,19 @@ def get_params(
return params if as_dict else params.values()
-
def set_tumor_spread_params(self, *args: float, **kwargs: float) -> tuple[float]:
"""Assign new parameters to the tumor spread edges."""
return set_params_for(self.graph.tumor_edges, *args, **kwargs)
-
def set_lnl_spread_params(self, *args: float, **kwargs: float) -> tuple[float]:
"""Assign new parameters to the LNL spread edges."""
return set_params_for(self.graph.lnl_edges, *args, **kwargs)
-
def set_spread_params(self, *args: float, **kwargs: float) -> tuple[float]:
"""Assign new parameters to the spread edges."""
args = self.set_tumor_spread_params(*args, **kwargs)
return self.set_lnl_spread_params(*args, **kwargs)
-
def set_params(self, *args: float, **kwargs: float) -> tuple[float]:
"""Assign new parameters to the model.
@@ -294,12 +287,7 @@ def set_params(self, *args: float, **kwargs: float) -> tuple[float]:
args = self.set_spread_params(*args, **kwargs)
return self.set_distribution_params(*args, **kwargs)
-
- def transition_prob(
- self,
- newstate: list[int],
- assign: bool = False
- ) -> float:
+ def transition_prob(self, newstate: list[int], assign: bool = False) -> float:
"""Compute probability to transition to ``newstate``, given its current state.
The probability is computed as the product of the transition probabilities of
@@ -308,7 +296,7 @@ def transition_prob(
"""
trans_prob = 1
for i, lnl in enumerate(self.graph.lnls):
- trans_prob *= lnl.comp_trans_prob(new_state = newstate[i])
+ trans_prob *= lnl.comp_trans_prob(new_state=newstate[i])
if trans_prob == 0:
break
@@ -317,10 +305,8 @@ def transition_prob(
return trans_prob
-
def diagnosis_prob(
- self,
- diagnosis: pd.Series | dict[str, dict[str, bool]]
+ self, diagnosis: pd.Series | dict[str, dict[str, bool]]
) -> float:
"""Compute the probability to observe a diagnosis given the current state.
@@ -332,7 +318,7 @@ def diagnosis_prob(
It returns the probability of observing this particular combination of
diagnosis, given the current state of the system.
"""
- prob = 1.
+ prob = 1.0
for name, modality in self.get_all_modalities().items():
if name in diagnosis:
mod_diagnosis = diagnosis[name]
@@ -348,13 +334,12 @@ def diagnosis_prob(
prob *= lnl.comp_obs_prob(lnl_diagnosis, modality.confusion_matrix)
return prob
-
@property
def obs_list(self):
"""Return the list of all possible observations.
- They are ordered the same way as the :py:attr:`.graph.Representation.state_list`,
- but additionally by modality. E.g., for two LNLs II, III and two modalities CT,
+ They are ordered like the :py:attr:`.graph.Representation.state_list`, but
+ additionally by modality. E.g., for two LNLs II, III and two modalities CT,
pathology, the list would look like this:
>>> model = Unilateral(graph_dict={
@@ -386,7 +371,6 @@ def obs_list(self):
return np.array(list(product(*possible_obs_list)))
-
def transition_matrix(self) -> np.ndarray:
r"""Matrix encoding the probabilities to transition from one state to another.
@@ -421,15 +405,14 @@ def transition_matrix(self) -> np.ndarray:
num_states=3 if self.is_trinary else 2,
)
-
def observation_matrix(self) -> np.ndarray:
r"""Get the matrix encoding the probabilities to observe a certain diagnosis.
Every element in this matrix holds a probability to observe a certain diagnosis
(or combination of diagnosis, when using multiple diagnostic modalities) given
the current state of the system. It has the shape
- :math:`2^N \\times 2^\\{N \\times M\\}` where :math:`N` is the number of nodes in
- the graph and :math:`M` is the number of diagnostic modalities.
+ :math:`2^N \\times 2^\\{N \\times M\\}` where :math:`N` is the number of nodes
+ in the graph and :math:`M` is the number of diagnostic modalities.
See Also
--------
@@ -443,7 +426,6 @@ def observation_matrix(self) -> np.ndarray:
base=3 if self.is_trinary else 2,
)
-
def data_matrix(self, t_stage: str | None = None) -> np.ndarray:
"""Extract the data matrix for a given ``t_stage``.
@@ -486,7 +468,6 @@ def data_matrix(self, t_stage: str | None = None) -> np.ndarray:
return self._data_matrix_cache[t_hash]
-
def diagnosis_matrix(self, t_stage: str | None = None) -> np.ndarray:
"""Extract the diagnosis matrix for a given ``t_stage``.
@@ -505,7 +486,6 @@ def diagnosis_matrix(self, t_stage: str | None = None) -> np.ndarray:
return self._diagnosis_matrix_cache[_hash].T
-
def load_patient_data(
self,
patient_data: pd.DataFrame,
@@ -534,8 +514,7 @@ def load_patient_data(
mapping = early_late_mapping
patient_data = (
- patient_data
- .copy()
+ patient_data.copy()
.drop(columns="_model", errors="ignore")
.reset_index(drop=True)
)
@@ -576,17 +555,15 @@ def load_patient_data(
category=types.DataWarning,
)
-
-
@property
def patient_data(self) -> pd.DataFrame:
"""Return the patient data loaded into the model.
- After succesfully loading the data with the method :py:meth:`.load_patient_data`,
- the copied patient data now contains the additional top-level header
- ``"_model"``. Under it, the observed per LNL involvement is listed for every
- diagnostic modality in the dictionary returned by :py:meth:`.get_all_modalities`
- and for each of the LNLs in the list :py:attr:`.graph.Representation.lnls`.
+ After successfully loading the data with :py:meth:`.load_patient_data`, the
+ copied patient data now contains the additional top-level header ``"_model"``.
+ Under it, the observed per LNL involvement is listed for every diagnostic
+ modality in the dictionary returned by :py:meth:`.get_all_modalities` and for
+ each of the LNLs in the list :py:attr:`.graph.Representation.lnls`.
It also contains information on the patient's T-stage under the header
``("_model", "#", "t_stage")``.
@@ -604,7 +581,6 @@ def patient_data(self) -> pd.DataFrame:
return self._patient_data
-
def evolve(self, state_dist: np.ndarray, num_steps: int) -> np.ndarray:
"""Evolve the ``state_dist`` of possible states over ``num_steps``.
@@ -618,7 +594,6 @@ def evolve(self, state_dist: np.ndarray, num_steps: int) -> np.ndarray:
return state_dist
-
def state_dist_evo(self) -> np.ndarray:
"""Compute an evolution of the model's state distribution over time steps.
@@ -631,14 +606,13 @@ def state_dist_evo(self) -> np.ndarray:
in the dictionary returned by :py:meth:`.get_all_distributions`.
"""
state_dists = np.zeros(shape=(self.max_time + 1, len(self.graph.state_list)))
- state_dists[0, 0] = 1.
+ state_dists[0, 0] = 1.0
for t in range(1, self.max_time + 1):
- state_dists[t] = self.evolve(state_dists[t-1], num_steps=1)
+ state_dists[t] = self.evolve(state_dists[t - 1], num_steps=1)
return state_dists
-
def state_dist(
self,
t_stage: str = "early",
@@ -671,6 +645,7 @@ def state_dist(
return state_dist
+ raise ValueError("Invalid mode. Must be either 'HMM' or 'BN'.")
def obs_dist(
self,
@@ -694,7 +669,6 @@ def obs_dist(
return given_state_dist @ self.observation_matrix()
-
def _bn_likelihood(self, log: bool = True, t_stage: str | None = None) -> float:
"""Compute the BN likelihood, using the stored params."""
state_dist = self.state_dist(mode="BN")
@@ -702,11 +676,10 @@ def _bn_likelihood(self, log: bool = True, t_stage: str | None = None) -> float:
return np.sum(np.log(patient_llhs)) if log else np.prod(patient_llhs)
-
def _hmm_likelihood(self, log: bool = True, t_stage: str | None = None) -> float:
"""Compute the HMM likelihood, using the stored params."""
evolved_model = self.state_dist_evo()
- llh = 0. if log else 1.
+ llh = 0.0 if log else 1.0
if t_stage is None:
t_stages = self.get_t_stages("valid")
@@ -723,7 +696,6 @@ def _hmm_likelihood(self, log: bool = True, t_stage: str | None = None) -> float
return llh
-
def likelihood(
self,
given_params: types.ParamsType | None = None,
@@ -745,7 +717,7 @@ def likelihood(
# given parameters are invalid...
utils.safe_set_params(self, given_params)
except ValueError:
- return -np.inf if log else 0.
+ return -np.inf if log else 0.0
if mode == "HMM":
return self._hmm_likelihood(log, t_stage)
@@ -754,7 +726,6 @@ def likelihood(
raise ValueError("Invalid mode. Must be either 'HMM' or 'BN'.")
-
def compute_encoding(
self,
given_diagnosis: types.DiagnosisType | None = None,
@@ -768,13 +739,12 @@ def compute_encoding(
matrix.compute_encoding(
lnls=self.graph.lnls.keys(),
pattern=given_diagnosis.get(modality, {}),
- base=2, # diagnosis are always binary!
+ base=2, # diagnosis are always binary!
),
)
return diagnosis_encoding
-
def posterior_state_dist(
self,
given_params: types.ParamsType | None = None,
@@ -830,7 +800,6 @@ def posterior_state_dist(
# specified diagnosis P(X|Z=z) = P(Z=z,X) / P(X), where P(X) = sum_z P(Z=z,X)
return joint_diagnosis_and_state / np.sum(joint_diagnosis_and_state)
-
def marginalize(
self,
involvement: types.PatternType,
@@ -857,7 +826,6 @@ def marginalize(
)
return marginalize_over_states @ given_state_dist
-
def risk(
self,
involvement: types.PatternType,
@@ -891,8 +859,7 @@ def risk(
# interest
return self.marginalize(involvement, posterior_state_dist)
-
- def draw_diagnosis(
+ def draw_diagnosis( # noqa: F811
self,
diag_times: list[int],
rng: np.random.Generator | None = None,
@@ -932,13 +899,11 @@ def draw_diagnosis(
obs_indices = np.arange(len(self.obs_list))
drawn_obs_idx = [
- rng.choice(obs_indices, p=obs_prob)
- for obs_prob in obs_probs_given_time
+ rng.choice(obs_indices, p=obs_prob) for obs_prob in obs_probs_given_time
]
return self.obs_list[drawn_obs_idx].astype(bool)
-
def draw_patients(
self,
num: int,
@@ -970,7 +935,7 @@ def draw_patients(
if rng is None:
rng = np.random.default_rng(seed)
- if sum(stage_dist) != 1.:
+ if sum(stage_dist) != 1.0:
warnings.warn("Sum of stage distribution is not 1. Renormalizing.")
stage_dist = np.array(stage_dist) / sum(stage_dist)
diff --git a/lymph/types.py b/lymph/types.py
index 065acdd..7bbe66b 100644
--- a/lymph/types.py
+++ b/lymph/types.py
@@ -1,5 +1,5 @@
-"""Type aliases and protocols used in the lymph package.
-"""
+"""Type aliases and protocols used in the lymph package."""
+
from abc import ABC, abstractmethod
from collections.abc import Iterable
from typing import Literal, Protocol, TypeVar
@@ -16,6 +16,7 @@ class HasSetParams(Protocol):
"""Protocol for classes that have a ``set_params`` method."""
def set_params(self, *args: float, **kwargs: float) -> tuple[float]:
+ """Set the parameters of the class."""
...
@@ -27,6 +28,7 @@ def get_params(
as_dict: bool = True,
as_flat: bool = True,
) -> tuple[float] | dict[str, float]:
+ """Return the parameters of the class."""
...
@@ -50,9 +52,15 @@ def get_params(
"""
InvolvementIndicator = Literal[
- False, 0, "healthy",
- True, 1, "involved",
- "micro", "macro", "notmacro",
+ False,
+ 0,
+ "healthy",
+ True,
+ 1,
+ "involved",
+ "micro",
+ "macro",
+ "notmacro",
]
"""Type alias for how to encode lymphatic involvement for a single lymph node level.
@@ -85,6 +93,7 @@ def get_params(
ModelT = TypeVar("ModelT", bound="Model")
+
class Model(ABC):
"""Abstract base class for models.
diff --git a/lymph/utils.py b/lymph/utils.py
index 45d3d29..0ef57f9 100644
--- a/lymph/utils.py
+++ b/lymph/utils.py
@@ -1,5 +1,5 @@
-"""Module containing supporting classes and functions used accross the project.
-"""
+"""Module containing supporting classes and functions used accross the project."""
+
import logging
from collections.abc import Sequence
from functools import cached_property, lru_cache, wraps
@@ -12,7 +12,6 @@
logger = logging.getLogger(__name__)
-
def check_unique_names(graph: dict):
"""Check all nodes in ``graph`` have unique names and no duplicate connections."""
node_name_set = set()
@@ -34,7 +33,7 @@ def check_spsn(spsn: list[float]):
"""Check whether specificity and sensitivity are valid."""
has_len_2 = len(spsn) == 2
is_above_lb = np.all(np.greater_equal(spsn, 0.5))
- is_below_ub = np.all(np.less_equal(spsn, 1.))
+ is_below_ub = np.all(np.less_equal(spsn, 1.0))
if not has_len_2 or not is_above_lb or not is_below_ub:
raise ValueError(
"For each modality provide a list of two decimals between 0.5 and 1.0 as "
@@ -66,31 +65,31 @@ def comp_transition_tensor(
tensor = np.stack([np.eye(num_child)] * num_parent)
# this should allow edges from trinary nodes to binary nodes
- pad = [0.] * (num_child - 2)
+ pad = [0.0] * (num_child - 2)
if is_tumor_spread:
# NOTE: Here we define how tumors spread to LNLs
- tensor[0, 0, :] = np.array([1. - spread_prob, spread_prob, *pad])
+ tensor[0, 0, :] = np.array([1.0 - spread_prob, spread_prob, *pad])
return tensor
if is_growth:
# In the growth case, we can assume that two things:
# 1. parent and child state are the same
# 2. the child node is trinary
- tensor[1, 1, :] = np.array([0., (1 - spread_prob), spread_prob])
+ tensor[1, 1, :] = np.array([0.0, (1 - spread_prob), spread_prob])
return tensor
if num_parent == 3:
# NOTE: here we define how the micro_mod affects the spread probability
micro_spread = spread_prob * micro_mod
- tensor[1,0,:] = np.array([1. - micro_spread, micro_spread, *pad])
+ tensor[1, 0, :] = np.array([1.0 - micro_spread, micro_spread, *pad])
macro_spread = spread_prob
- tensor[2,0,:] = np.array([1. - macro_spread, macro_spread, *pad])
+ tensor[2, 0, :] = np.array([1.0 - macro_spread, macro_spread, *pad])
return tensor
- tensor[1,0,:] = np.array([1. - spread_prob, spread_prob, *pad])
+ tensor[1, 0, :] = np.array([1.0 - spread_prob, spread_prob, *pad])
return tensor
@@ -101,28 +100,30 @@ def clinical(spsn: list) -> np.ndarray:
"""
check_spsn(spsn)
sp, sn = spsn
- confusion_matrix = np.array([
- [sp , 1. - sp],
- [sp , 1. - sp],
- [1. - sn, sn ],
- ])
- return confusion_matrix
+ return np.array(
+ [
+ [sp, 1.0 - sp],
+ [sp, 1.0 - sp],
+ [1.0 - sn, sn],
+ ]
+ )
def pathological(spsn: list) -> np.ndarray:
"""Produce the confusion matrix of a pathological modality.
A pathological modality can detect microscopic disease, but is unable to
- differentiante between micro- and macroscopic involvement.
+ differentiate between micro- and macroscopic involvement.
"""
check_spsn(spsn)
sp, sn = spsn
- confusion_matrix = np.array([
- [sp , 1. - sp],
- [1. - sn, sn ],
- [1. - sn, sn ],
- ])
- return confusion_matrix
+ return np.array(
+ [
+ [sp, 1.0 - sp],
+ [1.0 - sn, sn],
+ [1.0 - sn, sn],
+ ]
+ )
def tile_and_repeat(
@@ -160,7 +161,7 @@ def tile_and_repeat(
@lru_cache
def get_state_idx_matrix(lnl_idx: int, num_lnls: int, num_states: int) -> np.ndarray:
- """Return the indices for the transition tensor correpsonding to ``lnl_idx``.
+ """Return the indices for the transition tensor corresponding to ``lnl_idx``.
>>> get_state_idx_matrix(1, 3, 2)
array([[0, 0, 0, 0, 0, 0, 0, 0],
@@ -183,7 +184,7 @@ def get_state_idx_matrix(lnl_idx: int, num_lnls: int, num_states: int) -> np.nda
[2, 2, 2, 2, 2, 2, 2, 2, 2]])
"""
indices = np.arange(num_states).reshape(num_states, -1)
- block = np.tile(indices, (num_states ** lnl_idx, num_states ** num_lnls))
+ block = np.tile(indices, (num_states**lnl_idx, num_states**num_lnls))
return np.repeat(block, num_states ** (num_lnls - lnl_idx - 1), axis=0)
@@ -219,25 +220,29 @@ def early_late_mapping(t_stage: int | str) -> str:
def trigger(func: callable) -> callable:
- """Decorator that runs instance's ``trigger_callbacks`` when called."""
+ """Decorator that runs instance's ``trigger_callbacks`` when called.""" # noqa: D401
+
@wraps(func)
def wrapper(self, *args, **kwargs):
result = func(self, *args, **kwargs)
for callback in self.trigger_callbacks:
callback()
return result
+
return wrapper
-class smart_updating_dict_cached_property(cached_property):
+class smart_updating_dict_cached_property(cached_property): # noqa: N801
"""Allows setting/deleting dict-like attrs by updating/clearing them."""
def __set__(self, instance: object, value: Any) -> None:
+ """Update the dict-like attribute with the given value."""
dict_like = self.__get__(instance)
dict_like.clear()
dict_like.update(value)
def __delete__(self, instance: object) -> None:
+ """Clear the dict-like attribute."""
dict_like = self.__get__(instance)
dict_like.clear()
@@ -250,6 +255,7 @@ def dict_to_func(mapping: dict[Any, Any]) -> callable:
>>> char_map('a')
1
"""
+
def callable_mapping(key):
return mapping[key]
@@ -273,7 +279,7 @@ def popfirst(seq: Sequence[Any]) -> tuple[Any, Sequence[Any]]:
return None, seq
-def flatten(mapping, parent_key='', sep='_') -> dict:
+def flatten(mapping, parent_key="", sep="_") -> dict:
"""Flatten a nested dictionary.
>>> flatten({"a": {"b": 1, "c": 2}, "d": 3})
@@ -383,7 +389,7 @@ def draw_diagnosis(
rng: np.random.Generator | None = None,
seed: int = 42,
) -> np.ndarray:
- """Given the ``diagnosis_times`` and a hidden ``state_evolution``, draw diagnosis."""
+ """Draw diagnosis given ``diagnosis_times`` and hidden ``state_evolution``."""
if rng is None:
rng = np.random.default_rng(seed)
diff --git a/pyproject.toml b/pyproject.toml
index d8388a9..f151b69 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -82,8 +82,13 @@ ensure_newline_before_comments = true
[tool.pycln]
all = true
+
+[tool.ruff]
+exclude = ["tests", "docs"]
+
[tool.ruff.lint]
select = ["E", "F", "W", "B", "C", "R", "U", "D", "I", "S", "T", "A", "N", "NPY201"]
+ignore = ["B028"]
# git-cliff ~ default configuration file
diff --git a/tests/bayesian_unilateral_test.py b/tests/bayesian_unilateral_test.py
index f73ceb5..a7dde2f 100644
--- a/tests/bayesian_unilateral_test.py
+++ b/tests/bayesian_unilateral_test.py
@@ -1,5 +1,5 @@
-"""Test the Bayesian Unilateral Model.
-"""
+"""Test the Bayesian Unilateral Model."""
+
import numpy as np
from . import fixtures
@@ -30,13 +30,13 @@ def test_obs_dist(self):
def test_log_likelihood_smaller_zero(self):
"""Test the likelihood."""
likelihood = self.model.likelihood(mode="BN")
- self.assertLessEqual(likelihood, 0.)
+ self.assertLessEqual(likelihood, 0.0)
def test_likelihood_invalid_params_isinf(self):
"""Make sure the likelihood is `-np.inf` for invalid parameters."""
random_params = self.create_random_params()
for name in random_params:
- random_params[name] += 1.
+ random_params[name] += 1.0
likelihood = self.model.likelihood(
given_params=random_params,
log=True,
diff --git a/tests/binary_bilateral_test.py b/tests/binary_bilateral_test.py
index 93c73ea..95edde3 100644
--- a/tests/binary_bilateral_test.py
+++ b/tests/binary_bilateral_test.py
@@ -1,5 +1,4 @@
-"""Test the bilateral model.
-"""
+"""Test the bilateral model."""
import numpy as np
@@ -13,10 +12,12 @@ class BilateralInitTest(fixtures.BilateralModelMixin, fixtures.IgnoreWarningsTes
"""Test the delegation of attrs from the unilateral class to the bilateral one."""
def setUp(self):
- self.model_kwargs = {"is_symmetric": {
- "tumor_spread": True,
- "lnl_spread": True,
- }}
+ self.model_kwargs = {
+ "is_symmetric": {
+ "tumor_spread": True,
+ "lnl_spread": True,
+ }
+ }
super().setUp()
self.load_patient_data()
@@ -31,16 +32,14 @@ def test_transition_matrix_sync(self):
"""Make sure contra transition matrix gets recomputed when ipsi param is set."""
ipsi_trans_mat = self.model.ipsi.transition_matrix()
contra_trans_mat = self.model.contra.transition_matrix()
- rand_ipsi_param = self.rng.choice(list(
- self.model.ipsi.get_params(as_dict=True).keys()
- ))
+ rand_ipsi_param = self.rng.choice(
+ list(self.model.ipsi.get_params(as_dict=True).keys())
+ )
self.model.set_params(**{f"ipsi_{rand_ipsi_param}": self.rng.random()})
- self.assertFalse(np.all(
- ipsi_trans_mat == self.model.ipsi.transition_matrix()
- ))
- self.assertFalse(np.all(
- contra_trans_mat == self.model.contra.transition_matrix()
- ))
+ self.assertFalse(np.all(ipsi_trans_mat == self.model.ipsi.transition_matrix()))
+ self.assertFalse(
+ np.all(contra_trans_mat == self.model.contra.transition_matrix())
+ )
def test_modality_sync(self):
"""Make sure the modalities are synced between the two sides."""
@@ -199,10 +198,18 @@ def test_get_params_as_dict(self):
def test_set_params_as_args(self):
"""Test that the parameters can be set."""
- ipsi_tumor_spread_args = self.rng.uniform(size=len(self.model.ipsi.graph.tumor_edges))
- ipsi_lnl_spread_args = self.rng.uniform(size=len(self.model.ipsi.graph.lnl_edges))
- contra_tumor_spread_args = self.rng.uniform(size=len(self.model.contra.graph.tumor_edges))
- contra_lnl_spread_args = self.rng.uniform(size=len(self.model.contra.graph.lnl_edges))
+ ipsi_tumor_spread_args = self.rng.uniform(
+ size=len(self.model.ipsi.graph.tumor_edges)
+ )
+ ipsi_lnl_spread_args = self.rng.uniform(
+ size=len(self.model.ipsi.graph.lnl_edges)
+ )
+ contra_tumor_spread_args = self.rng.uniform(
+ size=len(self.model.contra.graph.tumor_edges)
+ )
+ contra_lnl_spread_args = self.rng.uniform(
+ size=len(self.model.contra.graph.lnl_edges)
+ )
dist_params = self.rng.uniform(size=len(self.model.get_distribution_params()))
self.model.set_params(
@@ -265,7 +272,9 @@ def test_get_params_as_dict(self):
def test_set_params_as_args(self):
"""Test that the parameters can be set."""
- args_to_set = [self.rng.uniform() for _ in self.model.ipsi.get_params(as_dict=False)]
+ args_to_set = [
+ self.rng.uniform() for _ in self.model.ipsi.get_params(as_dict=False)
+ ]
self.model.set_params(*args_to_set)
self.assertEqual(args_to_set, list(self.model.contra.get_params().values()))
@@ -323,7 +332,7 @@ def test_posterior_state_dist(self):
)
self.assertEqual(posterior.shape, (num_states, num_states))
self.assertEqual(posterior.dtype, float)
- self.assertTrue(np.isclose(posterior.sum(), 1.))
+ self.assertTrue(np.isclose(posterior.sum(), 1.0))
def test_risk(self):
"""Test that the risk is computed correctly."""
@@ -331,7 +340,9 @@ def test_risk(self):
random_diagnosis = self.create_random_diagnosis()
random_pattern = {
"ipsi": fixtures.create_random_pattern(self.model.ipsi.graph.lnls.keys()),
- "contra": fixtures.create_random_pattern(self.model.contra.graph.lnls.keys()),
+ "contra": fixtures.create_random_pattern(
+ self.model.contra.graph.lnls.keys()
+ ),
}
random_t_stage = self.rng.choice(["early", "late"])
@@ -341,8 +352,8 @@ def test_risk(self):
given_diagnosis=random_diagnosis,
t_stage=random_t_stage,
)
- self.assertLessEqual(risk, 1.)
- self.assertGreaterEqual(risk, 0.)
+ self.assertLessEqual(risk, 1.0)
+ self.assertGreaterEqual(risk, 0.0)
class DataGenerationTestCase(
@@ -373,6 +384,5 @@ def test_generate_data(self):
self.assertIn(lnl, dataset[mod][side])
self.assertAlmostEqual(
- (dataset["tumor", "1", "t_stage"] == "early").mean(), 0.5,
- delta=0.02
+ (dataset["tumor", "1", "t_stage"] == "early").mean(), 0.5, delta=0.02
)
diff --git a/tests/binary_midline_test.py b/tests/binary_midline_test.py
index 3585995..30ff19a 100644
--- a/tests/binary_midline_test.py
+++ b/tests/binary_midline_test.py
@@ -1,5 +1,4 @@
-"""Test the midline model for the binary case.
-"""
+"""Test the midline model for the binary case."""
import numpy as np
import pandas as pd
@@ -24,7 +23,6 @@ def test_init(self) -> None:
self.assertTrue(self.model.use_mixing)
self.assertFalse(self.model.is_trinary)
-
def test_set_spread_params(self) -> None:
"""Check that the complex parameter assignment works correctly."""
params_to_set = {k: self.rng.uniform() for k in self.model.get_params().keys()}
@@ -51,11 +49,10 @@ def test_set_spread_params(self) -> None:
self.model.noext.ipsi.get_tumor_spread_params(),
)
-
def test_get_set_params_order(self) -> None:
"""Check if the order of getter and setter is the same."""
num_dims = self.model.get_num_dims()
- params_to_set = np.linspace(0., 1., num_dims + 1)
+ params_to_set = np.linspace(0.0, 1.0, num_dims + 1)
unused_param = self.model.set_params(*params_to_set)
returned_params = list(self.model.get_params(as_dict=False))
@@ -73,12 +70,11 @@ def setUp(self) -> None:
"""Set up the test case."""
super().setUp()
self.init_diag_time_dists(early="frozen", late="parametric")
- self.model.set_modality("pathology", spec=1., sens=1., kind="pathological")
+ self.model.set_modality("pathology", spec=1.0, sens=1.0, kind="pathological")
self.model.load_patient_data(
- pd.read_csv("./tests/data/2021-clb-oropharynx.csv", header=[0,1,2]),
+ pd.read_csv("./tests/data/2021-clb-oropharynx.csv", header=[0, 1, 2]),
)
-
def test_likelihood(self) -> None:
"""Check that the likelihood function works correctly."""
params_to_set = {k: self.rng.uniform() for k in self.model.get_params().keys()}
@@ -104,7 +100,7 @@ def setUp(self) -> None:
"""Set up the test case."""
super().setUp()
self.init_diag_time_dists(early="frozen", late="parametric")
- self.model.set_modality("pathology", spec=1., sens=1., kind="pathological")
+ self.model.set_modality("pathology", spec=1.0, sens=1.0, kind="pathological")
self.model.set_params(
midext_prob=0.1,
ipsi_TtoII_spread=0.35,
@@ -116,7 +112,6 @@ def setUp(self) -> None:
late_p=0.5,
)
-
def test_risk(self) -> None:
"""Check that the risk method works correctly."""
lnlIII_risk = self.model.risk(involvement={"ipsi": {"II": False, "III": True}})
@@ -138,13 +133,14 @@ def test_risk(self) -> None:
self.assertGreater(contra_lnlII_risk, noext_contra_lnlII_risk)
self.assertGreater(ext_contra_lnlII_risk, noext_contra_lnlII_risk)
-
def test_risk_given_state_dist(self) -> None:
"""Check how providing a state distribution works correctly."""
state_dist_3d = self.model.state_dist(t_stage="early")
self.assertEqual(state_dist_3d.shape, (2, 4, 4))
- risk_from_state_dist = self.model.risk(given_state_dist=state_dist_3d, midext=True)
+ risk_from_state_dist = self.model.risk(
+ given_state_dist=state_dist_3d, midext=True
+ )
risk_direct = self.model.risk(midext=True)
self.assertTrue(np.allclose(risk_from_state_dist, risk_direct))
@@ -159,7 +155,6 @@ def test_risk_given_state_dist(self) -> None:
self.assertTrue(np.allclose(risk_from_state_dist, risk_direct))
-
class MidlineDrawPatientsTestCase(fixtures.IgnoreWarningsTestCase):
"""Check the data generation."""
@@ -179,10 +174,9 @@ def setUp(self) -> None:
marginalize_unknown=False,
uni_kwargs={"max_time": 2},
)
- self.model.set_distribution("early", [0., 1., 0.])
- self.model.set_distribution("late", [0., 0., 1.])
- self.model.set_modality("pathology", spec=1., sens=1., kind="pathological")
-
+ self.model.set_distribution("early", [0.0, 1.0, 0.0])
+ self.model.set_distribution("late", [0.0, 0.0, 1.0])
+ self.model.set_modality("pathology", spec=1.0, sens=1.0, kind="pathological")
def test_draw_patients(self) -> None:
"""Check that the data generation works correctly."""
diff --git a/tests/binary_unilateral_test.py b/tests/binary_unilateral_test.py
index c2df921..601114a 100644
--- a/tests/binary_unilateral_test.py
+++ b/tests/binary_unilateral_test.py
@@ -43,13 +43,17 @@ def test_num_nodes(self):
def test_num_edges(self):
"""Check number of edges initialized."""
- num_edges = sum(len(receiving_nodes) for receiving_nodes in self.graph_dict.values())
+ num_edges = sum(
+ len(receiving_nodes) for receiving_nodes in self.graph_dict.values()
+ )
num_tumor_edges = sum(
- len(receiving_nodes) for (kind, _), receiving_nodes in self.graph_dict.items()
+ len(receiving_nodes)
+ for (kind, _), receiving_nodes in self.graph_dict.items()
if kind == "tumor"
)
num_lnl_edges = sum(
- len(receiving_nodes) for (kind, _), receiving_nodes in self.graph_dict.items()
+ len(receiving_nodes)
+ for (kind, _), receiving_nodes in self.graph_dict.items()
if kind == "lnl"
)
@@ -128,9 +132,7 @@ def test_transition_matrix_deletion(self):
first_lnl_name = list(self.model.graph.lnls.values())[0].name
trans_mat = self.model.transition_matrix()
self.model.graph.edges[f"Tto{first_lnl_name}"].set_spread_prob(0.5)
- self.assertFalse(np.all(
- trans_mat == self.model.transition_matrix()
- ))
+ self.assertFalse(np.all(trans_mat == self.model.transition_matrix()))
class TransitionMatrixTestCase(
@@ -147,12 +149,14 @@ def setUp(self):
def test_shape(self):
"""Make sure the transition matrix has the correct shape."""
num_lnls = len({name for kind, name in self.graph_dict if kind == "lnl"})
- self.assertEqual(self.model.transition_matrix().shape, (2**num_lnls, 2**num_lnls))
+ self.assertEqual(
+ self.model.transition_matrix().shape, (2**num_lnls, 2**num_lnls)
+ )
def test_is_probabilistic(self):
"""Make sure the rows of the transition matrix sum to one."""
row_sums = np.sum(self.model.transition_matrix(), axis=1)
- self.assertTrue(np.allclose(row_sums, 1.))
+ self.assertTrue(np.allclose(row_sums, 1.0))
@staticmethod
def is_recusively_upper_triangular(mat: np.ndarray) -> bool:
@@ -167,12 +171,14 @@ def is_recusively_upper_triangular(mat: np.ndarray) -> bool:
for i in [0, 1]:
for j in [0, 1]:
return TransitionMatrixTestCase.is_recusively_upper_triangular(
- mat[i * half:(i + 1) * half, j * half:(j + 1) * half]
+ mat[i * half : (i + 1) * half, j * half : (j + 1) * half]
)
def test_is_recusively_upper_triangular(self) -> None:
"""Make sure the transition matrix is recursively upper triangular."""
- self.assertTrue(self.is_recusively_upper_triangular(self.model.transition_matrix()))
+ self.assertTrue(
+ self.is_recusively_upper_triangular(self.model.transition_matrix())
+ )
class ObservationMatrixTestCase(
@@ -190,13 +196,13 @@ def test_shape(self):
"""Make sure the observation matrix has the correct shape."""
num_lnls = len(self.model.graph.lnls)
num_modalities = len(self.model.get_all_modalities())
- expected_shape = (2**num_lnls, 2**(num_lnls * num_modalities))
+ expected_shape = (2**num_lnls, 2 ** (num_lnls * num_modalities))
self.assertEqual(self.model.observation_matrix().shape, expected_shape)
def test_is_probabilistic(self):
"""Make sure the rows of the observation matrix sum to one."""
row_sums = np.sum(self.model.observation_matrix(), axis=1)
- self.assertTrue(np.allclose(row_sums, 1.))
+ self.assertTrue(np.allclose(row_sums, 1.0))
class PatientDataTestCase(
@@ -218,7 +224,7 @@ def test_load_empty_dataframe(self):
"""Make sure the patient data is loaded correctly."""
self.model.load_patient_data(self.raw_data.iloc[:0])
self.assertEqual(len(self.model.patient_data), 0)
- self.assertEqual(self.model.likelihood(), 0.)
+ self.assertEqual(self.model.likelihood(), 0.0)
def test_load_patient_data(self):
"""Make sure the patient data is loaded correctly."""
@@ -229,7 +235,9 @@ def test_t_stages(self):
t_stages_in_data = self.model.get_t_stages("data")
t_stages_in_diag_time_dists = self.model.get_t_stages("distributions")
t_stages_in_model = self.model.get_t_stages("valid")
- t_stages_intersection = set(t_stages_in_data).intersection(t_stages_in_diag_time_dists)
+ t_stages_intersection = set(t_stages_in_data).intersection(
+ t_stages_in_diag_time_dists
+ )
self.assertNotIn("foo", t_stages_in_model)
self.assertEqual(len(t_stages_in_diag_time_dists), 3)
@@ -243,10 +251,12 @@ def test_t_stages(self):
def test_data_matrices(self):
"""Make sure the data matrices are generated correctly."""
for t_stage in ["early", "late"]:
- has_t_stage = self.raw_data["tumor", "1", "t_stage"].isin({
- "early": [0,1,2],
- "late": [3,4],
- }[t_stage])
+ has_t_stage = self.raw_data["tumor", "1", "t_stage"].isin(
+ {
+ "early": [0, 1, 2],
+ "late": [3, 4],
+ }[t_stage]
+ )
data_matrix = self.model.data_matrix(t_stage).T
self.assertEqual(
@@ -261,10 +271,12 @@ def test_data_matrices(self):
def test_diagnosis_matrices(self):
"""Make sure the diagnosis matrices are generated correctly."""
for t_stage in ["early", "late"]:
- has_t_stage = self.raw_data["tumor", "1", "t_stage"].isin({
- "early": [0,1,2],
- "late": [3,4],
- }[t_stage])
+ has_t_stage = self.raw_data["tumor", "1", "t_stage"].isin(
+ {
+ "early": [0, 1, 2],
+ "late": [3, 4],
+ }[t_stage]
+ )
diagnosis_matrix = self.model.diagnosis_matrix(t_stage).T
self.assertEqual(
@@ -277,10 +289,12 @@ def test_diagnosis_matrices(self):
)
# some times, entries in the diagnosis matrix are almost one, but just
# slightly larger. That's why we also have to have the `isclose` here.
- self.assertTrue(np.all(
- np.isclose(diagnosis_matrix, 1.)
- | np.less_equal(diagnosis_matrix, 1.)
- ))
+ self.assertTrue(
+ np.all(
+ np.isclose(diagnosis_matrix, 1.0)
+ | np.less_equal(diagnosis_matrix, 1.0)
+ )
+ )
def test_modality_replacement(self) -> None:
"""Check if the data & diagnosis matrices get updated when modalities change."""
@@ -314,13 +328,13 @@ def setUp(self):
def test_log_likelihood_smaller_zero(self):
"""Make sure the log-likelihood is smaller than zero."""
likelihood = self.model.likelihood(log=True, mode="HMM")
- self.assertLess(likelihood, 0.)
+ self.assertLess(likelihood, 0.0)
def test_likelihood_invalid_params_isinf(self):
"""Make sure the likelihood is `-np.inf` for invalid parameters."""
random_params = self.create_random_params()
for name in random_params:
- random_params[name] += 1.
+ random_params[name] += 1.0
likelihood = self.model.likelihood(
given_params=random_params,
log=True,
@@ -363,7 +377,7 @@ def test_compute_encoding(self):
random_diagnosis = self.create_random_diagnosis()
num_lnls = len(self.model.graph.lnls)
num_mods = len(self.model.get_all_modalities())
- num_posible_diagnosis = 2**(num_lnls * num_mods)
+ num_posible_diagnosis = 2 ** (num_lnls * num_mods)
diagnosis_encoding = self.model.compute_encoding(random_diagnosis)
self.assertEqual(diagnosis_encoding.shape, (num_posible_diagnosis,))
@@ -376,9 +390,9 @@ def test_posterior_state_dist(self):
given_diagnosis=self.create_random_diagnosis(),
t_stage=self.rng.choice(["early", "late"]),
)
- self.assertEqual(posterior_state_dist.shape, (2**len(self.model.graph.lnls),))
+ self.assertEqual(posterior_state_dist.shape, (2 ** len(self.model.graph.lnls),))
self.assertEqual(posterior_state_dist.dtype, float)
- self.assertTrue(np.isclose(np.sum(posterior_state_dist), 1.))
+ self.assertTrue(np.isclose(np.sum(posterior_state_dist), 1.0))
def test_risk(self):
"""Make sure the risk is correctly computed."""
@@ -394,8 +408,8 @@ def test_risk(self):
t_stage=random_t_stage,
)
self.assertEqual(risk.dtype, float)
- self.assertGreaterEqual(risk, 0.)
- self.assertLessEqual(risk, 1.)
+ self.assertGreaterEqual(risk, 0.0)
+ self.assertLessEqual(risk, 1.0)
class DataGenerationTestCase(
@@ -415,7 +429,7 @@ def test_generate_early_patients(self):
"""Check that generating only early T-stage patients works."""
early_patients = self.model.draw_patients(
num=100,
- stage_dist=[1., 0.],
+ stage_dist=[1.0, 0.0],
rng=self.rng,
)
self.assertEqual(len(early_patients), 100)
@@ -427,7 +441,7 @@ def test_generate_late_patients(self):
"""Check that generating only late T-stage patients works."""
late_patients = self.model.draw_patients(
num=100,
- stage_dist=[0., 1.],
+ stage_dist=[0.0, 1.0],
rng=self.rng,
)
self.assertEqual(len(late_patients), 100)
@@ -439,14 +453,14 @@ def test_distribution_of_patients(self):
"""Check that the distribution of LNL involvement is correct."""
# set spread params all to 0
for lnl_edge in self.model.graph.lnl_edges.values():
- lnl_edge.set_spread_prob(0.)
+ lnl_edge.set_spread_prob(0.0)
# make all patients diagnosisd after exactly one time-step
- self.model.set_distribution("early", [0,1,0,0,0,0,0,0,0,0,0])
+ self.model.set_distribution("early", [0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0])
# assign only one pathology modality
self.model.clear_modalities()
- self.model.set_modality("tmp", spec=1., sens=1.)
+ self.model.set_modality("tmp", spec=1.0, sens=1.0)
# extract the tumor spread parameters
params = self.model.get_params(as_dict=True)
@@ -459,7 +473,7 @@ def test_distribution_of_patients(self):
# draw large enough amount of patients
patients = self.model.draw_patients(
num=10000,
- stage_dist=[1., 0.],
+ stage_dist=[1.0, 0.0],
rng=self.rng,
)
diff --git a/tests/distribution_test.py b/tests/distribution_test.py
index a8575c1..e3a1b5f 100644
--- a/tests/distribution_test.py
+++ b/tests/distribution_test.py
@@ -1,4 +1,5 @@
"""Check functionality of the distribution over diagnosis times."""
+
import warnings
import numpy as np
@@ -23,15 +24,14 @@ def binom_pmf(
raise ValueError("max_time must be a positive integer.")
if len(support) != max_time + 1:
raise ValueError("support must have length max_time + 1.")
- if not 0. <= p <= 1.:
+ if not 0.0 <= p <= 1.0:
raise ValueError("p must be between 0 and 1.")
return sp.stats.binom.pmf(support, max_time, p)
-
def setUp(self):
self.max_time = 10
- self.array_arg = np.random.uniform(size=self.max_time + 1, low=0., high=10.)
+ self.array_arg = np.random.uniform(size=self.max_time + 1, low=0.0, high=10.0)
self.func_arg = lambda support, p=0.5: self.binom_pmf(support, self.max_time, p)
@@ -47,7 +47,7 @@ def test_frozen_distribution_without_max_time(self):
self.assertEqual({}, dist.get_params(as_dict=True))
self.assertTrue(len(dist.support) == self.max_time + 1)
self.assertTrue(len(dist.pmf) == self.max_time + 1)
- self.assertTrue(np.allclose(sum(dist.pmf), 1.))
+ self.assertTrue(np.allclose(sum(dist.pmf), 1.0))
def test_frozen_distribution_with_max_time(self):
"""Test the creation of a frozen distribution where we provide the max_time."""
@@ -58,7 +58,7 @@ def test_frozen_distribution_with_max_time(self):
self.assertEqual({}, dist.get_params(as_dict=True))
self.assertTrue(len(dist.support) == self.max_time + 1)
self.assertTrue(len(dist.pmf) == self.max_time + 1)
- self.assertTrue(np.allclose(sum(dist.pmf), 1.))
+ self.assertTrue(np.allclose(sum(dist.pmf), 1.0))
self.assertRaises(ValueError, Distribution, self.array_arg, max_time=5)
@@ -74,7 +74,7 @@ def test_updateable_distribution_with_max_time(self):
dist.set_params(p=0.5)
self.assertTrue(len(dist.support) == self.max_time + 1)
self.assertTrue(len(dist.pmf) == self.max_time + 1)
- self.assertTrue(np.allclose(sum(dist.pmf), 1.))
+ self.assertTrue(np.allclose(sum(dist.pmf), 1.0))
def test_updateable_distribution_raises_value_error(self):
"""Check that an invalid parameter raises a ValueError."""
diff --git a/tests/doc_test.py b/tests/doc_test.py
index e0052cc..0482870 100644
--- a/tests/doc_test.py
+++ b/tests/doc_test.py
@@ -1,4 +1,5 @@
"""Make doctests in the lymph package discoverable by unittest."""
+
import doctest
import unittest
diff --git a/tests/edge_test.py b/tests/edge_test.py
index 482afe6..c595afb 100644
--- a/tests/edge_test.py
+++ b/tests/edge_test.py
@@ -1,4 +1,5 @@
"""Unit tests for the Edge class."""
+
import numpy as np
from lymph import graph
@@ -54,8 +55,8 @@ class TrinaryEdgeTestCase(fixtures.IgnoreWarningsTestCase):
def setUp(self) -> None:
super().setUp()
- parent = graph.LymphNodeLevel("parent", allowed_states=[0,1,2])
- child = graph.LymphNodeLevel("child", allowed_states=[0,1,2])
+ parent = graph.LymphNodeLevel("parent", allowed_states=[0, 1, 2])
+ child = graph.LymphNodeLevel("child", allowed_states=[0, 1, 2])
self.edge = graph.Edge(parent, child)
self.edge.spread_prob = 0.3
self.edge.micro_mod = 0.7
diff --git a/tests/emcee_intergration_test.py b/tests/emcee_intergration_test.py
index c220411..364a1fc 100644
--- a/tests/emcee_intergration_test.py
+++ b/tests/emcee_intergration_test.py
@@ -1,5 +1,4 @@
-"""Make sure the models work with the emcee package.
-"""
+"""Make sure the models work with the emcee package."""
import emcee
import numpy as np
@@ -18,7 +17,6 @@ def setUp(self):
self.model.set_modality("PET", spec=0.86, sens=0.79)
self.load_patient_data(filename="2021-usz-oropharynx.csv")
-
def test_emcee(self):
"""Test the emcee package with the Unilateral model."""
nwalkers, ndim = 50, len(self.model.get_params())
@@ -33,7 +31,7 @@ def test_emcee(self):
parameter_names=list(self.model.get_params().keys()),
)
sampler.run_mcmc(initial, nsteps, progress=True)
- samples = sampler.get_chain(discard=int(0.9*nsteps), flat=True)
+ samples = sampler.get_chain(discard=int(0.9 * nsteps), flat=True)
self.assertGreater(sampler.acceptance_fraction.mean(), 0.2)
self.assertLess(sampler.acceptance_fraction.mean(), 0.5)
self.assertTrue(np.all(samples.mean(axis=0) >= 0.0))
diff --git a/tests/fixtures.py b/tests/fixtures.py
index 5a606f2..19120a4 100644
--- a/tests/fixtures.py
+++ b/tests/fixtures.py
@@ -1,5 +1,5 @@
-"""Fxitures for tests.
-"""
+"""Fxitures for tests."""
+
import logging
import unittest
import warnings
@@ -84,11 +84,13 @@ def _create_random_frozen_dist(
unnormalized = rng.random(size=max_time + 1)
return unnormalized / np.sum(unnormalized)
+
def _create_random_parametric_dist(
max_time: int,
rng: np.random.Generator = RNG,
) -> diagnosis_times.Distribution:
"""Create a binomial diagnosis time distribution with random params."""
+
def _pmf(support: np.ndarray, p: float = rng.random()) -> np.ndarray:
return sp.stats.binom.pmf(support, p=p, n=max_time + 1)
@@ -97,6 +99,7 @@ def _pmf(support: np.ndarray, p: float = rng.random()) -> np.ndarray:
max_time=max_time,
)
+
def create_random_dist(
type_: str,
max_time: int,
@@ -114,10 +117,7 @@ def create_random_dist(
def create_random_pattern(lnls: list[str]) -> PatternType:
"""Create a random involvement pattern."""
- return {
- lnl: RNG.choice([True, False, None])
- for lnl in lnls
- }
+ return {lnl: RNG.choice([True, False, None]) for lnl in lnls}
class BinaryUnilateralModelMixin:
@@ -131,7 +131,6 @@ def setUp(self, graph_size: str = "large"):
self.model = Unilateral.binary(graph_dict=self.graph_dict)
self.logger = get_logger(level=logging.INFO)
-
def create_random_params(self) -> dict[str, float]:
"""Create random parameters for the model."""
params = {
@@ -141,14 +140,15 @@ def create_random_params(self) -> dict[str, float]:
}
with warnings.catch_warnings():
warnings.simplefilter("ignore", category=UserWarning)
- params.update({
- f"{t_stage}_{type_}": self.rng.random()
- for t_stage, dist in self.model.get_all_distributions().items()
- for type_ in dist.get_params(as_dict=True).keys()
- })
+ params.update(
+ {
+ f"{t_stage}_{type_}": self.rng.random()
+ for t_stage, dist in self.model.get_all_distributions().items()
+ for type_ in dist.get_params(as_dict=True).keys()
+ }
+ )
return params
-
def init_diag_time_dists(self, **dists) -> None:
"""Init the diagnosis time distributions."""
for t_stage, type_ in dists.items():
@@ -157,14 +157,13 @@ def init_diag_time_dists(self, **dists) -> None:
create_random_dist(type_, self.model.max_time, self.rng),
)
-
def load_patient_data(
self,
filename: str = "2021-clb-oropharynx.csv",
) -> None:
"""Load patient data from a CSV file."""
filepath = Path(__file__).parent / "data" / filename
- self.raw_data = pd.read_csv(filepath, header=[0,1,2])
+ self.raw_data = pd.read_csv(filepath, header=[0, 1, 2])
self.model.load_patient_data(self.raw_data, side="ipsi")
@@ -185,7 +184,6 @@ def setUp(self):
self.model.set_params(**self.create_random_params())
self.logger = get_logger(level=logging.INFO)
-
def init_diag_time_dists(self, **dists) -> None:
"""Init the diagnosis time distributions."""
for t_stage, type_ in dists.items():
@@ -194,7 +192,6 @@ def init_diag_time_dists(self, **dists) -> None:
create_random_dist(type_, self.model.max_time, self.rng),
)
-
def create_random_params(self) -> dict[str, float]:
"""Create a random set of parameters."""
params = self.model.get_params(as_dict=True)
@@ -204,18 +201,16 @@ def create_random_params(self) -> dict[str, float]:
return params
-
def load_patient_data(
self,
filename: str = "2021-usz-oropharynx.csv",
) -> None:
"""Load patient data from a CSV file."""
filepath = Path(__file__).parent / "data" / filename
- self.raw_data = pd.read_csv(filepath, header=[0,1,2])
+ self.raw_data = pd.read_csv(filepath, header=[0, 1, 2])
self.model.load_patient_data(self.raw_data)
-
class TrinaryFixtureMixin:
"""Mixin class for simple trinary model fixture creation."""
@@ -226,7 +221,6 @@ def setUp(self):
self.model = Unilateral.trinary(graph_dict=self.graph_dict)
self.logger = get_logger(level=logging.INFO)
-
def create_random_params(self) -> dict[str, float]:
"""Create random parameters for the model."""
params = {
@@ -236,15 +230,16 @@ def create_random_params(self) -> dict[str, float]:
}
with warnings.catch_warnings():
warnings.simplefilter("ignore", category=UserWarning)
- params.update({
- f"{t_stage}_{type_}": self.rng.random()
- for t_stage, dist in self.model.get_all_distributions().items()
- for type_ in dist.get_params(as_dict=True).keys()
- })
+ params.update(
+ {
+ f"{t_stage}_{type_}": self.rng.random()
+ for t_stage, dist in self.model.get_all_distributions().items()
+ for type_ in dist.get_params(as_dict=True).keys()
+ }
+ )
return params
-
def init_diag_time_dists(self, **dists) -> None:
"""Init the diagnosis time distributions."""
for t_stage, type_ in dists.items():
@@ -253,7 +248,6 @@ def init_diag_time_dists(self, **dists) -> None:
create_random_dist(type_, self.model.max_time, self.rng),
)
-
def get_modalities_subset(self, names: list[str]) -> dict[str, Modality]:
"""Create a dictionary of modalities."""
modalities_in_data = {
@@ -267,14 +261,13 @@ def get_modalities_subset(self, names: list[str]) -> dict[str, Modality]:
}
return {name: modalities_in_data[name] for name in names}
-
def load_patient_data(
self,
filename: str = "2021-clb-oropharynx.csv",
) -> None:
"""Load patient data from a CSV file."""
filepath = Path(__file__).parent / "data" / filename
- self.raw_data = pd.read_csv(filepath, header=[0,1,2])
+ self.raw_data = pd.read_csv(filepath, header=[0, 1, 2])
self.model.load_patient_data(self.raw_data, side="ipsi")
@@ -300,7 +293,6 @@ def setUp(
use_midext_evo=use_midext_evo,
)
-
def init_diag_time_dists(self, **dists) -> None:
"""Init the diagnosis time distributions."""
for t_stage, type_ in dists.items():
diff --git a/tests/graph_representation_test.py b/tests/graph_representation_test.py
index 4a8010c..b50c6b5 100644
--- a/tests/graph_representation_test.py
+++ b/tests/graph_representation_test.py
@@ -1,4 +1,5 @@
"""Test the graph representation class of the package."""
+
import numpy as np
from lymph import graph
diff --git a/tests/node_test.py b/tests/node_test.py
index 3497b45..1631a4c 100644
--- a/tests/node_test.py
+++ b/tests/node_test.py
@@ -1,4 +1,5 @@
"""Unit tests for the Node classes."""
+
from lymph import graph
from . import fixtures
diff --git a/tests/trinary_midline_test.py b/tests/trinary_midline_test.py
index 78dcc07..d3ef4c5 100644
--- a/tests/trinary_midline_test.py
+++ b/tests/trinary_midline_test.py
@@ -1,5 +1,5 @@
-"""Test the midline model for the binary case.
-"""
+"""Test the midline model for the binary case."""
+
from typing import Literal
import numpy as np
@@ -34,14 +34,12 @@ def setUp(
use_midext_evo=False,
)
-
def test_init(self) -> None:
"""Check some basic attributes."""
self.assertTrue(self.model.use_central)
self.assertTrue(self.model.use_mixing)
self.assertTrue(self.model.is_trinary)
-
def test_set_spread_params(self) -> None:
"""Check that the complex parameter assignment works correctly."""
params_to_set = {k: self.rng.uniform() for k in self.model.get_params().keys()}
diff --git a/tests/trinary_unilateral_test.py b/tests/trinary_unilateral_test.py
index 363c2dc..970fcf0 100644
--- a/tests/trinary_unilateral_test.py
+++ b/tests/trinary_unilateral_test.py
@@ -47,7 +47,9 @@ def test_edge_transition_tensors(self) -> None:
NOTE: I am using this only in debug mode to look a the tensors. I am not sure
how to test them yet.
"""
- base_edge_tensor = list(self.model.graph.tumor_edges.values())[0].transition_tensor
+ base_edge_tensor = list(self.model.graph.tumor_edges.values())[
+ 0
+ ].transition_tensor
row_sums = base_edge_tensor.sum(axis=2)
self.assertTrue(np.allclose(row_sums, 1.0))
@@ -55,7 +57,9 @@ def test_edge_transition_tensors(self) -> None:
row_sums = lnl_edge_tensor.sum(axis=2)
self.assertTrue(np.allclose(row_sums, 1.0))
- growth_edge_tensor = list(self.model.graph.growth_edges.values())[0].transition_tensor
+ growth_edge_tensor = list(self.model.graph.growth_edges.values())[
+ 0
+ ].transition_tensor
row_sums = growth_edge_tensor.sum(axis=2)
self.assertTrue(np.allclose(row_sums, 1.0))
@@ -83,7 +87,7 @@ def test_observation_matrix(self) -> None:
num_lnls = len(self.model.graph.lnls)
num = num_lnls * len(self.model.get_all_modalities())
observation_matrix = self.model.observation_matrix()
- self.assertEqual(observation_matrix.shape, (3 ** num_lnls, 2 ** num))
+ self.assertEqual(observation_matrix.shape, (3**num_lnls, 2**num))
row_sums = observation_matrix.sum(axis=1)
self.assertTrue(np.allclose(row_sums, 1.0))
@@ -108,9 +112,11 @@ def test_diagnosis_matrices_shape(self) -> None:
"""Test the diagnosis matrix of the model."""
for t_stage in ["early", "late"]:
num_lnls = len(self.model.graph.lnls)
- num_patients = (self.model.patient_data["_model", "#", "t_stage"] == t_stage).sum()
+ num_patients = (
+ self.model.patient_data["_model", "#", "t_stage"] == t_stage
+ ).sum()
diagnosis_matrix = self.model.diagnosis_matrix(t_stage).T
- self.assertEqual(diagnosis_matrix.shape, (3 ** num_lnls, num_patients))
+ self.assertEqual(diagnosis_matrix.shape, (3**num_lnls, num_patients))
class TrinaryParamAssignmentTestCase(
@@ -156,13 +162,13 @@ def setUp(self):
def test_log_likelihood_smaller_zero(self):
"""Make sure the log-likelihood is smaller than zero."""
likelihood = self.model.likelihood(log=True, mode="HMM")
- self.assertLess(likelihood, 0.)
+ self.assertLess(likelihood, 0.0)
def test_likelihood_invalid_params_isinf(self):
"""Make sure the likelihood is `-np.inf` for invalid parameters."""
random_params = self.create_random_params()
for name in random_params:
- random_params[name] += 1.
+ random_params[name] += 1.0
likelihood = self.model.likelihood(
given_params=random_params,
log=True,
@@ -197,10 +203,12 @@ def create_random_diagnosis(self):
def test_risk_is_probability(self):
"""Make sure the risk is a probability."""
risk = self.model.risk(
- involvement=fixtures.create_random_pattern(lnls=list(self.model.graph.lnls.keys())),
+ involvement=fixtures.create_random_pattern(
+ lnls=list(self.model.graph.lnls.keys())
+ ),
given_diagnosis=self.create_random_diagnosis(),
given_params=self.create_random_params(),
t_stage=self.rng.choice(["early", "late"]),
)
- self.assertGreaterEqual(risk, 0.)
- self.assertLessEqual(risk, 1.)
+ self.assertGreaterEqual(risk, 0.0)
+ self.assertLessEqual(risk, 1.0)
From 593b8c99d6e606d2771546d0f8afbb6b0253c6d7 Mon Sep 17 00:00:00 2001
From: Roman Ludwig <48687784+rmnldwg@users.noreply.github.com>
Date: Tue, 25 Jun 2024 15:13:48 +0200
Subject: [PATCH 6/7] docs(uni): remove outdated docstring paragraph
Fixes: #88
---
lymph/models/unilateral.py | 19 +++++++------------
1 file changed, 7 insertions(+), 12 deletions(-)
diff --git a/lymph/models/unilateral.py b/lymph/models/unilateral.py
index 0755f53..9c624d0 100644
--- a/lymph/models/unilateral.py
+++ b/lymph/models/unilateral.py
@@ -87,15 +87,10 @@ def __init__(
:py:meth:`~Unilateral.trinary`.
The ``max_time`` parameter defines the latest possible time step for a
- diagnosis. In the HMM case, the probability disitrubtion over all hidden states
+ diagnosis. In the HMM case, the probability distribution over all hidden states
is evolved from :math:`t=0` to ``max_time``. In the BN case, this parameter has
no effect.
- The ``is_micro_mod_shared`` and ``is_growth_shared`` parameters determine
- whether the microscopic involvement and growth parameters are shared among all
- LNLs. If they are set to ``True``, the parameters are set globally for all LNLs.
- If they are set to ``False``, the parameters are set individually for each LNL.
-
"""
self.graph = graph.Representation(
graph_dict=graph_dict,
@@ -287,8 +282,8 @@ def set_params(self, *args: float, **kwargs: float) -> tuple[float]:
args = self.set_spread_params(*args, **kwargs)
return self.set_distribution_params(*args, **kwargs)
- def transition_prob(self, newstate: list[int], assign: bool = False) -> float:
- """Compute probability to transition to ``newstate``, given its current state.
+ def transition_prob(self, new_state: list[int], assign: bool = False) -> float:
+ """Compute probability to transition to ``new_state``, given its current state.
The probability is computed as the product of the transition probabilities of
the individual LNLs. If ``assign`` is ``True``, the new state is assigned to
@@ -296,12 +291,12 @@ def transition_prob(self, newstate: list[int], assign: bool = False) -> float:
"""
trans_prob = 1
for i, lnl in enumerate(self.graph.lnls):
- trans_prob *= lnl.comp_trans_prob(new_state=newstate[i])
+ trans_prob *= lnl.comp_trans_prob(new_state=new_state[i])
if trans_prob == 0:
break
if assign:
- self.graph.set_state(newstate)
+ self.graph.set_state(new_state)
return trans_prob
@@ -793,7 +788,7 @@ def posterior_state_dist(
# vector containing P(Z=z|X). Essentially a data matrix for one patient
diagnosis_given_state = diagnosis_encoding @ self.observation_matrix().T
- # multiply P(Z=z|X) * P(X) elementwise to get vector of joint probs P(Z=z,X)
+ # multiply P(Z=z|X) * P(X) element-wise to get vector of joint probs P(Z=z,X)
joint_diagnosis_and_state = given_state_dist * diagnosis_given_state
# compute vector of probabilities for all possible involvements given the
@@ -843,7 +838,7 @@ def risk(
If no ``involvement`` is provided, this will simply return the posterior
distribution over hidden states, given the diagnosis, as computed by the
- :py:meth:`.posterior_state_dist` method. See its documentaiton for more
+ :py:meth:`.posterior_state_dist` method. See its documentation for more
details about the arguments and the return value.
"""
posterior_state_dist = self.posterior_state_dist(
From 4a7ab73b46e2485c012d31e913395c9bebb0b7d1 Mon Sep 17 00:00:00 2001
From: Roman Ludwig <48687784+rmnldwg@users.noreply.github.com>
Date: Tue, 25 Jun 2024 15:19:34 +0200
Subject: [PATCH 7/7] chore: update changelog
---
CHANGELOG.md | 100 ++++++++++++++++++++++++++++++++++++---------------
1 file changed, 71 insertions(+), 29 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 95b8748..5aebb46 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,8 +2,44 @@
All notable changes to this project will be documented in this file.
+
+
+## [1.2.2] - 2024-06-25
+
+### Bug Fixes
+
+- (**mid**) Correct contra state dist evo. Fixes [#85].\
+ Previously, the model did not correctly marginalize over the possible
+ time when a tumor can grow over the midline. It simply assumed that it
+ did from the onset.
+
+### Documentation
+
+- (**uni**) Remove outdated docstring paragraph. Fixes [#88].
+
+### Miscellaneous Tasks
+
+- Bump pre-commit versions.
+
+### Styling
+
+- Use ruff to fix lint and format code.
+
+### Build
+
+- Remove upper cap in deps.
+
+### Change
+
+- `risk()` meth requires `involvement`. Fixes [#87].\
+ We figured it does not make sense to allow passing `involvement=None`
+ into the `risk()` method just to have it return 1. This is except for
+ the midline class, where `involvement` may reasonably be `None` while
+ `midext` isn't.\
+ Also, I ran ruff over some files, fixing some code style issues.
+
## [1.2.1] - 2024-05-28
### Bug Fixes
@@ -20,7 +56,6 @@ All notable changes to this project will be documented in this file.
Previously, only the involvement pattern was checked. Now, the model is
more careful about when to take shortcuts.
-
### Features
- (**graph**) Modify mermaid graph.\
@@ -34,7 +69,6 @@ All notable changes to this project will be documented in this file.
This saves us a couple of lines in the `load_patient_data` method and is
more readable.
-
### Merge
- Branch 'main' into 'dev'.
@@ -45,22 +79,20 @@ All notable changes to this project will be documented in this file.
Some callback functionality that was tested in a pre-release has been
forgotten in the code base and is now deleted.
-
+
## [1.2.0] - 2024-03-29
### Bug Fixes
- (**mid**) `obs_dist` may return 3D array.
-
### Documentation
- Fix unknown version in title.
- Add missing blank before list.
- (**mid**) Add comment about midext marginalizing.
-
### Features
- (**mid**) Add `posterior_state_dist()` method.\
@@ -76,20 +108,18 @@ All notable changes to this project will be documented in this file.
The `types.Model` base abstract base class now also has the methods
`obs_dist` and `marginalize` for better autocomplete support in editors.
-
### Testing
- Remove plain test risk.
-
### Change
- (**types**) Improve type hints for inv. pattern.
- Rename "diagnose" to "diagnosis" when noun.\
When used as a noun, "diagnosis" is correct, not "diagnose".
-
+
## [1.1.0] - 2024-03-20
### Features
@@ -110,8 +140,8 @@ All notable changes to this project will be documented in this file.
- Add checks for midline risk. Related [#80].
- (**mid**) Fix wrong assumption in risk test.
-
+
## [1.0.0] - 2024-03-18
### Bug Fixes
@@ -146,15 +176,14 @@ All notable changes to this project will be documented in this file.
- Branch 'main' into 'dev'.
- Branch '79-loading-an-empty-dataframe-raises-error' into 'dev'.
-
+
## [1.0.0.rc2] - 2024-03-06
Implementing the [lymixture] brought to light a shortcoming in the way the data and diagnose matrices are computed and stored. As mentioned in issue [#77], their rows are now aligned with the patient data, which may have some advantages for different use cases.
Also, since this is probably the last pre-release, I took the liberty to go over some method names once more and make them clearer.
-
### Bug Fixes
- Don't use fake T-stage for BN model. Related [#77].\
@@ -165,7 +194,6 @@ Also, since this is probably the last pre-release, I took the liberty to go over
under the "_model" header in the `patient_data` table, we need to reload
the patient data whenever we modify the modalities.
-
### Documentation
- Update to slightly changed API.
@@ -186,7 +214,6 @@ Also, since this is probably the last pre-release, I took the liberty to go over
`dataframe.drop(columns)`. I replaced the former with the latter and now
the tests are fast again.
-
### Refactor
- ⚠ **BREAKING** Rename methods for brevity & clarity.\
@@ -213,7 +240,6 @@ Also, since this is probably the last pre-release, I took the liberty to go over
The `(uni|ipsi|contra)lateral_kwargs` in the `Bilateral` constructor
were shortened by removing the "lateral".
-
### Merge
- Branch 'main' into 'dev'.
@@ -223,8 +249,8 @@ Also, since this is probably the last pre-release, I took the liberty to go over
- Unused helpers.
-
+
## [1.0.0.rc1] - 2024-03-04
This release hopefully represents the last major change before releasing version 1.0.0. It was necessary because during the implementation of the midline model, managing the symmetries in a transparent and user-friendly way became impossible in the old implementation.
@@ -233,7 +259,6 @@ Now, a [composite pattern] is used for both the modalities and the distributions
[composite pattern]: https://refactoring.guru/design-patterns/composite
-
### Add
- First version of midline module added.
@@ -256,7 +281,6 @@ Now, a [composite pattern] is used for both the modalities and the distributions
Some bugs in the method for drawing synthetic patients from the
`Midline` were fixed. This seems to be working now.
-
### Documentation
- (**mid**) Improve midline docstrings slightly.
@@ -302,7 +326,6 @@ Now, a [composite pattern] is used for both the modalities and the distributions
`unknown`, which is a `Bilateral` model only used to store that data and
generate diagnose matrices.
-
### Miscellaneous Tasks
- Move timing data.
@@ -387,8 +410,8 @@ Now, a [composite pattern] is used for both the modalities and the distributions
- Unused helper functions.
-
+
## [1.0.0.a6] - 2024-02-15
With this (still alpha) release, we most notably fixed a long unnoticed bug in the computation of the Bayesian network likelihood.
@@ -415,8 +438,8 @@ With this (still alpha) release, we most notably fixed a long unnoticed bug in t
- (**uni**) Prohibit setting `max_time`
- ⚠ **BREAKING** Change `likelihood()` API: We don't allow setting the data via the `likelihood()` anymore. It convoluted the method and setting it beforehand is more explicit anyways.
-
+
## [1.0.0.a5] - 2024-02-06
In this alpha release we fixed more bugs and issues that emerged during more rigorous testing.
@@ -481,8 +504,8 @@ Instead, the function computing the transition matrix is now globally cached usi
- Unused files and directories
-
+
## [1.0.0.a4] - 2023-12-12
### Bug Fixes
@@ -530,8 +553,8 @@ Instead, the function computing the transition matrix is now globally cached usi
- Don't use custom subclass of `cached_property` that forbids setting and use the default `cached_property` instead
- Encode symmetries of `Bilateral` model in a special dict called `is_summetric` with keys `"tumor_spread"`, `"lnl_spread"`, and `"modalities"`
-
+
## [1.0.0.a3] - 2023-12-06
Fourth alpha release. [@YoelPH](https://github.com/YoelPH) noticed some more bugs that have been fixed now. Most notably, the risk prediction raised exceptions, because of a missing transponed matrix `.T`.
@@ -554,8 +577,8 @@ Fourth alpha release. [@YoelPH](https://github.com/YoelPH) noticed some more bug
- Test unilateral posterior state distribution for shape and sum
- Test bilateral posterior joint state distribution for shape and sum
-
+
## [1.0.0.a2] - 2023-09-15
Third alpha release. I am pretty confident that the `lymph.models.Unilateral` class works as intended since it _does_ yield the same results as the `0.4.3` version.
@@ -630,16 +653,18 @@ Also, I am now quite satisfied with the look and usability of the new API. Hopef
- Branch 'remove-descriptors' into 'reimplement-bilateral'
- Branch 'reimplement-bilateral' into 'dev'
-
+
## [1.0.0.a1] - 2023-08-30
Second alpha release, aimed at testing the all new implementation. See these [issues](https://github.com/rmnldwg/lymph/milestone/1) for an idea of what this tries to address.
### Bug Fixes
+
- (**matrix**) Wrong shape of observation matrix for trinary model
### Documentation
+
- Fix wrong python version in rtd config file
- Remove outdated sampling tutorial
- Remove deprecated read-the-docs config
@@ -647,42 +672,51 @@ Second alpha release, aimed at testing the all new implementation. See these [is
- Execute quickstart notebook
### Testing
-- Check correct shapes for trinary model matrices
+- Check correct shapes for trinary model matrices
+
## [1.0.0.a0] - 2023-08-15
This alpha release is a reimplementation most of the package's API. It aims to solve some [issues](https://github.com/rmnldwg/lymph/milestone/1) that accumulated for a while.
### Features
+
- parameters can now be assigned centrally via a `assign_params()` method, either using args or keyword arguments. This resolves [#46]
- expensive operations generally look expensive now, and do not just appear as if they were attribute assignments. Fixes [#40]
- computations around the the likelihood and risk predictions are now more modular. I.e., several conditional and joint probability vectors/matrices can now be computed conveniently and are not burried in large methods. Resolves isse [#41]
- support for the trinary model was added. This means lymph node levels (LNLs) can be in one of three states (healthy, microscopic involvement, macroscopic metatsasis), instead of only two (healthy, involved). Resolves [#45]
### Documentation
+
- module, class, method, and attribute docstrings should now be more detailed and helpful. We switched from strictly adhering to Numpy-style docstrings to something more akin to Python's core library docstrings. I.e., parameters and behaviour are explained in natural language.
- quickstart guide has been adapted to the new API
### Code Refactoring
+
- all matrices related to the underlying hidden Markov model (HMM) have been decoupled from the `Unilateral` model class
- the representation of the directed acyclic graph (DAG) that determined the directions of spread from tumor to and among the LNLs has been implemented in a separate class of which an instance provides access to it as an attribute of `Unilateral`
- access to all parameters of the graph (i.e., the edges) is bundled in a descriptor holding a `UserDict`
### BREAKING CHANGES
+
Almost the entire API has changed. I'd therefore recommend to have a look at the [quickstart guide](https://lymph-model.readthedocs.io/en/1.0.0.a0/quickstart.html) to see how the new model is used. Although most of the core concepts are still the same.
+
## [0.4.3] - 2022-09-02
### Bug Fixes
+
- incomplete involvement for unilateral risk method does not raise KeyError anymore. Fixes issue [#38]
+
## [0.4.2] - 2022-08-24
### Documentation
+
- fix the issue of docs failing to build
- remove outdated line in install instructions
- move conf.py back into source dir
@@ -691,28 +725,33 @@ Almost the entire API has changed. I'd therefore recommend to have a look at the
- more stable sphinx-build & update old index
### Maintenance
+
- fine-tune git-chglog settings to my needs
- start with a CHANGELOG
- add description to types of allowed commits
-
+
## [0.4.1] - 2022-08-23
+
### Bug Fixes
-- pyproject.toml referenced wrong README & LICENSE
+- pyproject.toml referenced wrong README & LICENSE
+
## [0.4.0] - 2022-08-23
+
### Code Refactoring
+
- delete unnecessary utils
### Maintenance
+
- fix pyproject.toml typo
- add pre-commit hook to check commit msg
-
-[Unreleased]: https://github.com/rmnldwg/lymph/compare/1.2.1...HEAD
+[1.2.2]: https://github.com/rmnldwg/lymph/compare/1.2.1...1.2.2
[1.2.1]: https://github.com/rmnldwg/lymph/compare/1.1.0...1.2.1
[1.2.0]: https://github.com/rmnldwg/lymph/compare/1.1.0...1.2.0
[1.1.0]: https://github.com/rmnldwg/lymph/compare/1.0.0...1.1.0
@@ -731,6 +770,9 @@ Almost the entire API has changed. I'd therefore recommend to have a look at the
[0.4.1]: https://github.com/rmnldwg/lymph/compare/0.4.0...0.4.1
[0.4.0]: https://github.com/rmnldwg/lymph/compare/0.3.10...0.4.0
+[#88]: https://github.com/rmnldwg/lymph/issues/88
+[#87]: https://github.com/rmnldwg/lymph/issues/87
+[#85]: https://github.com/rmnldwg/lymph/issues/85
[#80]: https://github.com/rmnldwg/lymph/issues/80
[#79]: https://github.com/rmnldwg/lymph/issues/79
[#77]: https://github.com/rmnldwg/lymph/issues/77