raphaelvallat/yasa

plot_hypnogram display could be clearer

remrama opened this issue · 1 comments

I'm somewhat confused about how hypnograms are represented with yasa.plot_hypnogram, and even somewhat suspicious that the plotted hypnogram is shifted (left) by 1 epoch. I suppose there are multiple ways to view a graph like this, especially how much interpretation you might put on vertical lines. I think the hypnogram includes stages that represent a chunk of time (e.g., epoch 1 with standard 1/30 sf represents the window between 0-30 seconds), whereas the current plotting with plt.step doesn't handle that well.

Examples below, but first I'll jump ahead to my conclusion: Maybe a combined use of ax.hlines (for the lines) and ax.stairs (for the fill) would make for a better and more accurate visualization?

Examples

Using some super-short simple hypnograms...

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import yasa

sf = 1/30

5-min hypnograms aren't 5 minutes

This example shows plots of 2 different hypnograms that should be 5 minutes long. They only differ by whether it takes 30 or 60 seconds to fall asleep. Note the difference in the first Wake epoch(s) between the two graphs. In the first graph, it should be 60 seconds long but it's 30. In the second graph, it should be 30 seconds long but it's... 0?

#### Draw 2 different plots,
#### 1 with 30-sec SOL, another with 60-sec SOL

hypno_str_a = ["W", "W", "N1", "N1", "N2", "N2", "N3", "N3", "R", "R"]
hypno_str_b = ["W", "N1", "N1", "N2", "N2", "N3", "N3", "R", "R", "W"]
hypno_int_a = yasa.hypno_str_to_int(hypno_str_a)
hypno_int_b = yasa.hypno_str_to_int(hypno_str_b)

# Some transformations usually done within yasa.plot_hypnogram
hypno_yvals_a = pd.Series(hypno_int_a).map({-2: -2, -1: -1, 0: 0, 1: 2, 2: 3, 3: 4, 4: 1}).mul(-1).values
hypno_yvals_b = pd.Series(hypno_int_b).map({-2: -2, -1: -1, 0: 0, 1: 2, 2: 3, 3: 4, 4: 1}).mul(-1).values
t_hyp = np.arange(hypno_yvals_a) / (sf * 60)

fig, axes = plt.subplots(nrows=2, figsize=(4, 6), sharex=True, sharey=True)
axes[0].step(t_hyp, hypno_yvals_a, color="black", lw=2)
axes[1].step(t_hyp, hypno_yvals_b, color="black", lw=2)
axes[0].set_title("5-min hypnogram with 30-sec SOL:\n" + " - ".join(hypno_str_a))
axes[1].set_title("5-min hypnogram with 30-sec SOL:\n" + " - ".join(hypno_str_b))

# Titles/labels/limits
axes[1].set_xlim(-0.5, 5.5)
axes[1].set_ylim(-4.5, 0.5)
axes[1].set_yticks([0, -1, -2, -3, -4])
axes[1].set_yticklabels(["W", "R", "N1", "N2", "N3"])
fig.supxlabel("Time [mins]")
plt.tight_layout()

example_5min_SOL

Zoom in on 1-min: flat line despite 2 stages

This time using YASA to make sure my code wasn't different from the underlying function.

#### Silly 2-epoch example to highlight the problem.
yasa.plot_hypnogram([0, 1], sf)
plt.suptitle("1-min hypnogram: W - N1")
plt.tight_layout()

example_1min

Alternatives

I like the use of plt.stairs() because of the explicit passing of bin edges. Also the fill option is nice, though you can see that in a case where a hypnogram ends on non-wake, it will automatically jump up to 0. This can be changed with parameters! Just pointing it out as something to be careful about with defaults. I actually like plt.hlines() because it avoids the whole vertical line thing.

# Make a super-simple super-short hypnogram.
# (5-minutes, decreasing through 2 30-s epochs of each stage.)
hypno_str = ["W", "W", "N1", "N1", "N2", "N2", "N3", "N3", "R", "R"]
hypno_int = yasa.hypno_str_to_int(hypno_str)

# Some transformations usually done within yasa.plot_hypnogram
hypno_yvals = pd.Series(hypno_int
    ).map({-2: -2, -1: -1, 0: 0, 1: 2, 2: 3, 3: 4, 4: 1}
    ).mul(-1).values

n_epochs = hypno_yvals.size
# x-values for plt.step, same size as hypno
t_hyp = np.arange(n_epochs) / (sf * 60)
# x-values for plt.stairs and plt.hlines, 1 longer than hypno
bins = np.arange(n_epochs + 1) / (sf * 60)

# Draw 3 different plots of the same data
fig, axes = plt.subplots(nrows=3, figsize=(4, 6), sharex=True, sharey=True)

axes[0].step(t_hyp, hypno_yvals, color="black", lw=2)
axes[0].set_title("ax.step()")

axes[1].stairs(hypno_yvals, bins, color="gainsboro", fill=True)
axes[1].stairs(hypno_yvals, bins, lw=2, color="black")
axes[1].set_title("ax.stairs(fill=True)")

axes[2].hlines(hypno_yvals, xmin=bins[:-1], xmax=bins[1:], lw=2, color="black")
axes[2].set_title("ax.hlines()")

# Titles/labels/limits
axes[2].set_xlim(-0.5, 5.5)
axes[2].set_ylim(-4.5, 0.5)
axes[2].set_yticks([0, -1, -2, -3, -4])
axes[2].set_yticklabels(["W", "R", "N1", "N2", "N3"])
fig.suptitle("5-min hypnogram:\n" + " - ".join(hypno_str))
fig.supxlabel("Time [mins]")
plt.tight_layout()

example_5min_custom

YASA's function

Again just showing that my code is providing the same output as YASA. This should match the first panel of previous image, where there should be 60 seconds of Wake to start, but there's 30.

yasa.plot_hypnogram(hypno_int, sf)
plt.suptitle("5-min hypnogram:\n" + " - ".join(hypno_str))
plt.tight_layout()

example_5min_yasa

Hi @remrama!

Thanks for the super detailed PR, loved it! Let's go with plt.stairs, it is indeed much better than the current plt.step. I think plt.hlines gets messy with longer hypnograms. I like the fill parameter too, definitely would like to add this as a customization parameter.

hypno_str = np.tile(["W", "W", "N1", "N2", "N2", "N2", "N3", "N3", "N2", "R", "R"], 10)

image