Skip to content
Merged
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
8c6df3e
sankey plots and their tests
Nov 26, 2025
d59b90d
pyproject.toml dependency added and AnnData from typehints removed
Nov 26, 2025
dcbdaf9
small fix
Nov 27, 2025
3a9c5f8
backend option matplotlib in addition to bokeh
Dec 3, 2025
1474da9
Merge branch 'main' into enhancement/issue-232-clean
sueoglu Dec 10, 2025
f672487
sankey added to init.py
Dec 10, 2025
542a9fc
sankey_time_plot test more complex with more timeseries and more states
Dec 10, 2025
14abe6e
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Dec 10, 2025
5900d21
backend argument in plotting functions removed
Dec 12, 2025
2326aa4
merge conflict resolve
Dec 12, 2025
c9e6b01
fix
Dec 12, 2025
cae40f6
fix
Dec 12, 2025
abcb9ba
deleted outcommented code and formatting fixed
Dec 12, 2025
3698734
formatting fix
Dec 12, 2025
bfa6949
comments addressed other than the one regarding the blobs for test an…
Dec 15, 2025
9457560
example and test blobs will be replaced except that done
Dec 17, 2025
b5c361f
fix
Dec 17, 2025
f15f8a3
Merge branch 'main' into enhancement/issue-232-clean
sueoglu Dec 17, 2025
ca28b80
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Dec 17, 2025
472f33b
fix
Dec 17, 2025
55fdde2
plot_index.md plots renamed
Dec 17, 2025
c9dbcad
submodule conflict
Dec 17, 2025
c076666
Merge branch 'main' into enhancement/issue-232-clean
Zethson Dec 17, 2025
ccd7c84
decorator for hv backend and sanke_diagram_time singledispatched supp…
Dec 17, 2025
6059424
Merge branch 'enhancement/issue-232-clean' of github.com:theislab/ehr…
Dec 17, 2025
b26a7a2
data for sankey time example & test with int casting
Dec 17, 2025
9edf320
small fix
Dec 17, 2025
51bd8af
fail early if not binned, error cases added
Dec 18, 2025
5f20e2d
error cases refined
Dec 19, 2025
44fb400
last fixes
Dec 19, 2025
3feaa93
Merge branch 'main' into enhancement/issue-232-clean
Zethson Dec 19, 2025
737170a
use to_dense from fau
Dec 19, 2025
a01313e
Merge branch 'enhancement/issue-232-clean' of github.com:theislab/ehr…
Dec 19, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added docs/_static/docstring_previews/sankey_time.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions docs/api/plot_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ For most tools and for some preprocessing functions, you will find a plotting fu
plot.ranking
plot.dendrogram
plot.catplot
plot.sankey_diagram
plot.sankey_diagram_time
```

## Quality Control and missing values
Expand Down
18 changes: 18 additions & 0 deletions ehrapy/_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from subprocess import PIPE, Popen
from typing import TYPE_CHECKING, ParamSpec, TypeVar, cast

import holoviews as hv
import numpy as np
import scipy.sparse as sp

Expand Down Expand Up @@ -256,3 +257,20 @@ def as_dense_dask_array(a, chunk_size=1000):
import dask.array as da

return da.from_array(a, chunks=chunk_size)


def choose_hv_backend():
Comment thread
sueoglu marked this conversation as resolved.
Outdated
def decorator(func: Callable[P, R]) -> Callable[P, R]:
@wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
if hv.Store.current_backend is None:
raise RuntimeError(
"No holoviews backend selected. "
"Call holoviews.extension('matplotlib') or "
"holoviews.extension('bokeh') before using this function."
)
return func(*args, **kwargs)

return wrapper

return decorator
1 change: 1 addition & 0 deletions ehrapy/plot/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
missing_values_heatmap,
missing_values_matrix,
)
from ehrapy.plot._sankey import sankey_diagram, sankey_diagram_time
from ehrapy.plot._scanpy_pl_api import (
clustermap,
dendrogram,
Expand Down
232 changes: 232 additions & 0 deletions ehrapy/plot/_sankey.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
from __future__ import annotations

from functools import singledispatch
from typing import TYPE_CHECKING, Any

import holoviews as hv
import numpy as np
import pandas as pd
from holoviews import opts

from ehrapy._compat import _raise_array_type_not_implemented, choose_hv_backend

if TYPE_CHECKING:
from collections.abc import Sequence

from ehrdata import EHRData


@choose_hv_backend()
def sankey_diagram(
edata: EHRData,
*,
columns: Sequence[str],
node_width: int | float = 20,
node_padding: int | float = 10,
node_color: str = None,
label_position: str | None = "right",
show_values: bool = True,
title: str | None = None,
width: int | None = 600,
height: int | None = 400,
**kwargs,
Comment thread
sueoglu marked this conversation as resolved.
) -> hv.Sankey:
"""Create a Sankey diagram of relationships across the flat observation table.

Args:
edata : Central data object.
Comment thread
sueoglu marked this conversation as resolved.
Outdated
columns : Column names from `edata.obs` to visualize
node_width : Width of the nodes in the Sankey diagram.
node_padding : Padding between nodes in the Sankey diagram.
node_color : Color of the nodes. If None, default coloring is used.
edge_color : Color of the edges. If None, default coloring is used.
label_position : Position of the labels on the nodes. Options are 'left', 'right', 'top', 'bottom', or 'center'.
show_values : Whether to display the values on the edges.
title : Title of the Sankey diagram.
width : Width of the Sankey diagram.
height : Height of the Sankey diagram.
**kwargs: Additional styling options passed to :class:`holoviews.element.sankey.Sankey`.

Comment thread
sueoglu marked this conversation as resolved.
Examples:
>>> import ehrdata as ed
Comment thread
sueoglu marked this conversation as resolved.
>>> edata = ed.dt.diabetes_130_fairlearn(columns_obs_only=["gender", "race"])
>>> ep.pl.sankey_diagram(edata, columns=["gender", "race"])
Comment thread
sueoglu marked this conversation as resolved.
"""
if hv.Store.current_backend is None:
Comment thread
sueoglu marked this conversation as resolved.
Outdated
raise RuntimeError(
"No holoviews backend selected. "
":func:`holoviews.extension` with ``matplotlib`` or ``bokeh`` must be called before using this function."
)
df = edata.obs[columns]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a series and not a Pandas DataFrame or am I wrong?


# Build links between consecutive columns
sources, targets, values = [], [], []
source_levels, target_levels = [], []
for i in range(len(columns) - 1):
col_from, col_to = columns[i], columns[i + 1]
flows = df.groupby([col_from, col_to]).size().reset_index(name="count")
sources.extend(col_from + ": " + flows[col_from].astype("string"))
targets.extend(col_to + ": " + flows[col_to].astype("string"))
values.extend(flows["count"].to_numpy())
source_levels.extend([col_from] * len(flows))
target_levels.extend([col_to] * len(flows))

sankey_df = pd.DataFrame(
{
"source": sources,
"target": targets,
"value": values,
"source_level": source_levels,
"target_level": target_levels,
}
)

sankey = hv.Sankey(sankey_df, kdims=["source", "target"], vdims=["value"])

opts_dict: dict[str, Any] = {}

if hv.Store.current_backend == "bokeh":
if width is not None:
opts_dict["width"] = width
if height is not None:
opts_dict["height"] = height

if node_width is not None:
opts_dict["node_width"] = node_width
if node_padding is not None:
opts_dict["node_padding"] = node_padding
if title is not None:
opts_dict["title"] = title
if node_color is not None:
opts_dict["node_color"] = node_color
if label_position is not None:
opts_dict["label_position"] = label_position
if show_values is not None:
opts_dict["show_values"] = show_values

opts_dict.update(kwargs)

sankey = sankey.opts(**opts_dict)
return sankey


@singledispatch
Comment thread
sueoglu marked this conversation as resolved.
Outdated
def _generate_sankey(mtx, time: list[Any], state_labels: dict[int, str] | None = None):
_raise_array_type_not_implemented(mtx, type(mtx))


@_generate_sankey.register(np.ndarray)
def _(mtx: np.ndarray, time: list[Any], state_labels: dict[int, str] | None = None) -> pd.DataFrame:
if state_labels is None:
unique_states = np.unique(mtx)
if np.issubdtype(unique_states.dtype, np.floating):
unique_states = unique_states[~np.isnan(unique_states)]

state_labels = {int(state): str(state) for state in unique_states}

state_values = sorted(state_labels.keys())
state_names = [state_labels[val] for val in state_values]

sources, targets, values = [], [], []
for t in range(len(time) - 1):
for s_from_idx, s_from_val in enumerate(state_values):
for s_to_idx, s_to_val in enumerate(state_values):
count = np.sum((mtx[:, t] == s_from_val) & (mtx[:, t + 1] == s_to_val))
if count > 0:
source_label = f"{state_names[s_from_idx]} ({time[t]})"
target_label = f"{state_names[s_to_idx]} ({time[t + 1]})"
sources.append(source_label)
targets.append(target_label)
values.append(int(count))

return pd.DataFrame({"source": sources, "target": targets, "value": values})


Comment thread
sueoglu marked this conversation as resolved.
@choose_hv_backend()
def sankey_diagram_time(
edata: EHRData,
*,
columns: Sequence[str],
layer: str,
state_labels: dict[int, str] | None = None,
node_width: int | float = 20,
node_padding: int | float = 10,
node_color: str = None,
label_position: str | None = "right",
show_values: bool = True,
title: str | None = None,
width: int | None = 600,
height: int | None = 400,
**kwargs,
Comment thread
sueoglu marked this conversation as resolved.
) -> hv.Sankey:
"""Create a Sankey diagram showing patient state transitions over time.

Each node represents a state at a specific time point, and flows show the
number of patients transitioning between states.
Comment thread
sueoglu marked this conversation as resolved.
Visualizes how patients transition between different states
(e.g. disease severity, treatment status) across consecutive time points.

Args:
edata: Central data object.
columns: Variable name from `edata.var_names` to visualize
layer: Name of the layer in `edata.layers` containing the feature data to visualize.
state_labels: Mapping from numeric state values to readable labels.
If None, state values will be displayed as strings of their numeric codes (e.g., "0", "1", "2").
node_width : Width of the nodes in the Sankey diagram.
Comment thread
sueoglu marked this conversation as resolved.
Outdated
node_padding : Padding between nodes in the Sankey diagram.
node_color : Color of the nodes. If None, default coloring is used.
edge_color : Color of the edges. If None, default coloring is used.
label_position : Position of the labels on the nodes. Options are 'left', 'right', 'outer', or 'inner'.
show_values : Whether to display the values on the edges.
title : Title of the Sankey diagram.
width : Width of the Sankey diagram.
height : Height of the Sankey diagram.
**kwargs: Additional styling options passed to :class:`holoviews.element.sankey.Sankey`.

Examples:
>>> import ehrdata as ed
>>> edata = ed.dt.ehrdata_blobs(base_timepoints=5, n_variables=1, n_observations=5, random_state=59)
>>> edata.layers["tem_data"] = edata.layers["tem_data"].astype(int)
>>> state_labels = {-2: "no", -3: "mild", -4: "moderate", -5: "severe", -6: "critical"}
>>> plot = sankey_diagram_time(
Comment thread
sueoglu marked this conversation as resolved.
Outdated
Comment thread
sueoglu marked this conversation as resolved.
Outdated
... edata,
... columns=["feature_0"],
... layer="tem_data",
... state_labels=state_labels,
... )

.. image:: /_static/docstring_previews/sankey_time.png
"""
flare_data = edata[:, edata.var_names.isin(columns), :].layers[layer][:, 0, :]
time_steps = edata.tem.index.tolist()

sankey_df = _generate_sankey(flare_data, time=time_steps, state_labels=state_labels)

sankey = hv.Sankey(sankey_df, kdims=["source", "target"], vdims=["value"])

opts_dict: dict[str, Any] = {}

if hv.Store.current_backend == "bokeh":
if width is not None:
opts_dict["width"] = width
if height is not None:
opts_dict["height"] = height

if node_width is not None:
opts_dict["node_width"] = node_width
if node_padding is not None:
opts_dict["node_padding"] = node_padding
if title is not None:
opts_dict["title"] = title
if node_color is not None:
opts_dict["node_color"] = node_color
if label_position is not None:
opts_dict["label_position"] = label_position
if show_values is not None:
opts_dict["show_values"] = show_values

opts_dict.update(kwargs)

sankey = sankey.opts(**opts_dict)

return sankey
Loading