Skip to content

Commit 039efa7

Browse files
authored
Merge pull request #111 from lycosystem/release-1.3.7
Release 1.3.7
2 parents b5c2f68 + a459d67 commit 039efa7

16 files changed

Lines changed: 253 additions & 71 deletions

.github/workflows/tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
name: tests
1+
name: Tests
22

33
on:
44
push:

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,25 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## [1.3.7] - 2025-10-23
6+
7+
### Bug Fixes
8+
9+
- Make models compatible with new data format.
10+
- Correct array shape mismatch in `draw_patients`.
11+
12+
### Miscellaneous Tasks
13+
14+
- Update codecov badge.
15+
- Mention wiki in contribution guide.
16+
- Update email addresses.
17+
18+
### Change
19+
20+
- Adjustments to allow bilateral mixture model.
21+
- Make compatible with new data format (again).
22+
- Change dataframe indexing to conform with new standard.
23+
524
## [1.3.6] - 2025-06-30
625

726
### Miscellaneous Tasks
@@ -948,6 +967,7 @@ Almost the entire API has changed. I'd therefore recommend to have a look at the
948967
- fix pyproject.toml typo
949968
- add pre-commit hook to check commit msg
950969

970+
[1.3.7]: https://github.com/lycosystem/lymph/compare/1.3.6...1.3.7
951971
[1.3.6]: https://github.com/lycosystem/lymph/compare/1.3.5...1.3.6
952972
[1.3.5]: https://github.com/lycosystem/lymph/compare/1.3.4...1.3.5
953973
[1.3.4]: https://github.com/lycosystem/lymph/compare/1.3.3...1.3.4

CITATION.cff

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ type: software
88
authors:
99
- given-names: Roman
1010
family-names: Ludwig
11-
email: roman.ludwig@usz.ch
11+
email: gygqdstu3@mozmail.com
1212
affiliation: University Hospital Zurich
1313
orcid: 'https://orcid.org/0000-0001-9434-328X'
1414
- given-names: Bertrand

CONTRIBUTING.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
First, thanks for considering to help out on this project!
44

5-
Before you start coding away and fixing some of the [:link:issues], let me give you an introduction of the standards and best-practices that we have tried to follow so far:
5+
> [!WARNING]
6+
> Before you start coding away and fixing some of the [:link:issues], please familiarize yourself with the conventions we try to adhere to. These are explained in our [:link:organization-wide wiki] on the one hand, and in this document as well. The content of the wiki is more general and probably also more up-to-date, so start there.
67
78
## Git
89

@@ -123,9 +124,10 @@ For more complicated stuff, we used [:link:pytest] and [:link:hypothesis] to cov
123124

124125
## Questions?
125126

126-
If there is still something unclear, feel free to reach out to me: [roman.ludwig@usz.ch](mailto:roman.ludwig@usz.ch).
127+
If there is still something unclear, feel free to reach out to us: [yoel.perezhaas@usz.ch](mailto:yoel.perezhaas@usz.ch) or [noemi.buehrer@usz.ch](mailto:noemi.buehrer@usz.ch).
127128

128129
[:link:issues]: https://github.com/lycosystem/lymph/issues
130+
[:link:organization-wide wiki]: https://github.com/lycosystem/.github/wiki
129131
[:link:git]: https://git-scm.com
130132
[:link:GitHub]: https://github.com
131133
[:link:git-flow]: https://nvie.com/posts/a-successful-git-branching-model/

README.rst

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@
1010
:target: https://github.com/lycosystem/lymph/actions
1111
.. image:: https://github.com/lycosystem/lymph/actions/workflows/release.yml/badge.svg?style=flat
1212
:target: https://pypi.org/project/lymph-model/
13-
.. image:: https://codecov.io/gh/lycosystem/lymph/branch/main/graph/badge.svg?token=LPXQPK5K78
14-
:target: https://codecov.io/gh/lycosystem/lymph
1513
.. image:: https://readthedocs.org/projects/lymph-model/badge
1614
:target: https://lymph-model.readthedocs.io
15+
.. image:: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/lycosystem/lymph/python-coverage-comment-action-data/endpoint.json
16+
:target: https://htmlpreview.github.io/?https://github.com/lycosystem/lymph/blob/python-coverage-comment-action-data/htmlcov/index.html
1717

1818

1919
A Python package for statistical modelling of lymphatic metastatic spread in head & neck squamous cell carcinoma (HNSCC).

pyproject.toml

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ build-backend = "setuptools.build_meta"
55
[project]
66
name = "lymph-model"
77
description = "Package for statistical modelling of lymphatic metastatic spread."
8-
authors = [{ name = "Roman Ludwig", email = "roman.ludwig@usz.ch" }]
8+
authors = [
9+
{ name = "Roman Ludwig", email = "gygqdstu3@mozmail.com" },
10+
{ name = "Yoel Pérez Haas", email = "yoel.perezhaas@usz.ch" },
11+
{ name = "Noemi Bührer", email = "noemi.buehrer@usz.ch" },
12+
]
913
readme = "README.rst"
1014
requires-python = ">=3.10"
1115
keywords = ["cancer", "metastasis", "lymphatic progression", "model"]
@@ -98,8 +102,10 @@ select = [
98102
ignore = ["B028"]
99103

100104
[tool.ruff.lint.per-file-ignores]
101-
"test/*" = [
105+
"tests/*" = [
106+
"D102",
102107
"S101",
108+
"RET503",
103109
]
104110

105111

src/lymph/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
__version__ = version
1616
__description__ = "Package for statistical modelling of lymphatic metastatic spread."
1717
__author__ = "Roman Ludwig"
18-
__email__ = "roman.ludwig@usz.ch"
18+
__email__ = "gygqdstu3@mozmail.com"
1919
__uri__ = "https://github.com/lycosystem/lymph"
2020

2121
# nopycln: file

src/lymph/models/bilateral.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,15 @@ def load_patient_data(
404404
"""
405405
self.ipsi.load_patient_data(patient_data, "ipsi", mapping)
406406
self.contra.load_patient_data(patient_data, "contra", mapping)
407+
# Keep all columns except '_model'
408+
# From '_model' only keep those with first subheader '#' or 'core'
409+
cols = [col for col in self.ipsi.patient_data.columns if col[0] != "_model"]
410+
cols += [
411+
col
412+
for col in self.ipsi.patient_data.columns
413+
if col[0] == "_model" and (col[1] == "#" or col[1] == "core")
414+
]
415+
self.patient_data = self.ipsi.patient_data[cols]
407416

408417
def state_dist(
409418
self,
@@ -472,10 +481,15 @@ def patient_likelihoods(
472481
mode: Literal["HMM", "BN"] = "HMM",
473482
) -> np.ndarray:
474483
"""Compute the likelihood of each patient individually."""
475-
joint_state_dist = self.state_dist(t_stage=t_stage, mode=mode)
476-
return matrix.fast_trace(
477-
self.ipsi.diagnosis_matrix(t_stage),
478-
joint_state_dist @ self.contra.diagnosis_matrix(t_stage).T,
484+
if mode == "HMM":
485+
joint_state_dist = self.state_dist(t_stage=t_stage, mode=mode)
486+
return matrix.fast_trace(
487+
self.ipsi.diagnosis_matrix(t_stage),
488+
joint_state_dist @ self.contra.diagnosis_matrix(t_stage).T,
489+
)
490+
raise NotImplementedError(
491+
f"Mode '{mode}' not implemented for patient likelihoods. "
492+
"Only 'HMM? is supported.",
479493
)
480494

481495
def _bn_likelihood(self, log: bool = True, t_stage: str | None = None) -> float:
@@ -730,6 +744,6 @@ def draw_patients(
730744
dataset = pd.DataFrame(drawn_obs, columns=multi_cols)
731745
dataset = dataset.reorder_levels(order=[1, 0, 2], axis="columns")
732746
dataset = dataset.sort_index(axis="columns", level=0)
733-
dataset[("tumor", "1", "t_stage")] = drawn_t_stages
747+
dataset[("tumor", "core", "t_stage")] = drawn_t_stages
734748

735749
return dataset

src/lymph/models/hpv.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@
1717
logger = logging.getLogger(__name__)
1818

1919

20+
RAW_HPV_COL_OLD = ("patient", "#", "hpv_status")
21+
RAW_HPV_COL_NEW = ("patient", "core", "hpv_status")
22+
23+
2024
def select_hpv_model(method):
2125
"""Decorate methods that simply delegate to the `hpv` or `nohpv` model."""
2226

@@ -299,8 +303,9 @@ def load_patient_data(
299303
method of both the HPV+ and the HPV- model.
300304
"""
301305
# TODO: What about patients with unknown HPV status?
302-
is_hpv_pos = patient_data["patient", "#", "hpv_status"] == True # noqa: E712
303-
is_hpv_neg = patient_data["patient", "#", "hpv_status"] == False # noqa: E712
306+
hpv_data = utils.get_item(patient_data, [RAW_HPV_COL_NEW, RAW_HPV_COL_OLD])
307+
is_hpv_pos = hpv_data == True # noqa: E712
308+
is_hpv_neg = hpv_data == False # noqa: E712
304309

305310
hpv_patient_data = patient_data.loc[is_hpv_pos]
306311
nohpv_patient_data = patient_data.loc[is_hpv_neg]

src/lymph/models/midline.py

Lines changed: 77 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,13 @@
1616
logger = logging.getLogger(__name__)
1717

1818

19-
EXT_COL = ("tumor", "1", "extension")
20-
CENTRAL_COL = ("tumor", "1", "central")
19+
EXT_COL_OLD = ("tumor", "1", "extension")
20+
CENT_COL_OLD = ("tumor", "1", "central")
21+
EXT_COL_NEW = ("tumor", "core", "extension")
22+
MAP_EXT_COL = ("_model", "core", "extension")
23+
MAP_CENT_COL = ("_model", "core", "central")
24+
CENTRAL_COL_NEW = ("tumor", "core", "central")
25+
MAP_T_COL = ("_model", "core", "t_stage")
2126

2227

2328
class Midline(
@@ -505,10 +510,10 @@ def load_patient_data(
505510
This amounts to sorting the patients into three bins:
506511
507512
1. Patients whose tumor is clearly lateralized, meaning the column
508-
``("tumor", "1", "extension")`` reports ``False``. These get assigned to
513+
``("tumor", "core", "extension")`` reports ``False``. These get assigned to
509514
the :py:attr:`.noext` attribute.
510515
2. Those with a central tumor, indicated by ``True`` in the column
511-
``("tumor", "1", "central")``. If the :py:attr:`.use_central` attribute is
516+
``("tumor", "core", "central")``. If the :py:attr:`.use_central` attribute is
512517
set to ``True``, these patients are assigned to the :py:attr:`.central`
513518
model. Otherwise, they are assigned to the :py:attr:`.ext` model.
514519
3. The rest, which amounts to patients whose tumor extends over the mid-sagittal
@@ -519,13 +524,20 @@ def load_patient_data(
519524
the respective models.
520525
"""
521526
# pylint: disable=singleton-comparison
522-
is_lateralized = patient_data[EXT_COL] == False # noqa: E712
523-
has_extension = patient_data[EXT_COL] == True # noqa: E712
524-
is_unknown = patient_data[EXT_COL].isna()
527+
midext_data = utils.get_item(patient_data, [EXT_COL_NEW, EXT_COL_OLD])
528+
# first load complete data into noext to assign the loaded dataset to self
529+
self.noext.load_patient_data(patient_data, mapping)
530+
main_data = self.noext.patient_data.copy()
531+
main_data[MAP_EXT_COL] = midext_data
532+
self.patient_data = main_data
533+
is_lateralized = midext_data == False # noqa: E712
534+
has_extension = midext_data == True # noqa: E712
535+
is_unknown = midext_data.isna()
525536
self.noext.load_patient_data(patient_data[is_lateralized], mapping)
526537

527538
if self.use_central:
528-
is_central = patient_data[CENTRAL_COL] == True # noqa: E712
539+
central_data = utils.get_item(patient_data, [CENTRAL_COL_NEW, CENT_COL_OLD])
540+
is_central = central_data == True # noqa: E712
529541
has_extension = has_extension & ~is_central
530542
self.central.load_patient_data(patient_data[is_central], mapping)
531543

@@ -669,6 +681,56 @@ def obs_dist(
669681
]
670682
return np.stack(obs_dist)
671683

684+
def patient_likelihoods(
685+
self,
686+
t_stage: str = None,
687+
mode: Literal["HMM", "BN"] = "HMM",
688+
) -> np.ndarray:
689+
"""Compute the likelihood of each patient individually."""
690+
if mode != "HMM":
691+
raise NotImplementedError("Only HMM mode is supported as of now.")
692+
ipsi_dist_evo = self.ext.ipsi.state_dist_evo()
693+
contra_dist_evo = {}
694+
contra_dist_evo["noext"], contra_dist_evo["ext"] = self.contra_state_dist_evo()
695+
t_stages = self.t_stages if t_stage is None else [t_stage]
696+
patient_data = self.patient_data.loc[
697+
self.patient_data[MAP_T_COL].isin(t_stages)
698+
]
699+
patient_llhs = np.zeros(len(patient_data))
700+
for stage in t_stages:
701+
t_idx = patient_data[MAP_T_COL] == stage
702+
diag_time_matrix = np.diag(self.get_distribution(stage).pmf)
703+
num_states = ipsi_dist_evo.shape[1]
704+
marg_joint_state_dist = np.zeros(shape=(num_states, num_states))
705+
# see the `Bilateral` model for why this is done in this way.
706+
for case in ["ext", "noext"]:
707+
ext_idx = patient_data[MAP_EXT_COL] == (case == "ext")
708+
joint_state_dist = (
709+
ipsi_dist_evo.T @ diag_time_matrix @ contra_dist_evo[case]
710+
)
711+
marg_joint_state_dist += joint_state_dist
712+
_model = getattr(self, case)
713+
llhs = matrix.fast_trace(
714+
_model.ipsi.diagnosis_matrix(stage),
715+
joint_state_dist @ _model.contra.diagnosis_matrix(stage).T,
716+
)
717+
patient_llhs[t_idx & ext_idx] = llhs
718+
719+
try:
720+
marg_patient_llhs = matrix.fast_trace(
721+
self.unknown.ipsi.diagnosis_matrix(stage),
722+
marg_joint_state_dist
723+
@ self.unknown.contra.diagnosis_matrix(stage).T,
724+
)
725+
patient_llhs[t_idx & patient_data[MAP_EXT_COL].isna()] = (
726+
marg_patient_llhs
727+
)
728+
except AttributeError:
729+
# an AttributeError is raised both when the model has no `unknown`
730+
# attribute and when no data is loaded in the `unknown` model.
731+
pass
732+
return patient_llhs
733+
672734
def _hmm_likelihood(
673735
self,
674736
log: bool = True,
@@ -959,7 +1021,10 @@ def draw_patients(
9591021
)
9601022

9611023
ipsi_evo = self.ext.ipsi.state_dist_evo()
962-
drawn_diags = np.empty(shape=(num, len(self.ext.ipsi.obs_list)))
1024+
drawn_diags = np.empty(
1025+
shape=(num, self.ext.ipsi.obs_list.shape[1] * 2),
1026+
dtype=bool,
1027+
)
9631028
for case in ["ext", "noext"]:
9641029
case_model = getattr(self, case)
9651030
drawn_ipsi_diags = utils.draw_diagnosis(
@@ -996,8 +1061,8 @@ def draw_patients(
9961061
dataset = pd.DataFrame(drawn_diags, columns=multi_cols)
9971062
dataset = dataset.reorder_levels(order=[1, 0, 2], axis="columns")
9981063
dataset = dataset.sort_index(axis="columns", level=0)
999-
dataset["tumor", "1", "t_stage"] = drawn_t_stages
1000-
dataset["tumor", "1", "extension"] = drawn_midexts
1001-
dataset["patient", "#", "diagnosis_time"] = drawn_diag_times
1064+
dataset["tumor", "core", "t_stage"] = drawn_t_stages
1065+
dataset["tumor", "core", "extension"] = drawn_midexts
1066+
dataset["patient", "core", "diagnosis_time"] = drawn_diag_times
10021067

10031068
return dataset

0 commit comments

Comments
 (0)