From 6e501e9b11bc9acbcaad3230cf0e9afce5d5240d Mon Sep 17 00:00:00 2001 From: Maxime Liquet <35924738+maximlt@users.noreply.github.com> Date: Tue, 4 Jun 2024 15:39:45 +0100 Subject: [PATCH] Document subcoordinate_group_ranges by extending the EEG Viewer demo (#6243) Co-authored-by: Demetris Roumis --- doc/conf.py | 4 + examples/gallery/demos/bokeh/eeg_viewer.ipynb | 172 ------------- .../multichannel_timeseries_viewer.ipynb | 238 ++++++++++++++++++ 3 files changed, 242 insertions(+), 172 deletions(-) delete mode 100644 examples/gallery/demos/bokeh/eeg_viewer.ipynb create mode 100644 examples/gallery/demos/bokeh/multichannel_timeseries_viewer.ipynb diff --git a/doc/conf.py b/doc/conf.py index 1c2cb85a52..c2b031e72e 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -67,6 +67,10 @@ 'goatcounter_holoviz': True, } +rediraffe_redirects = { + 'gallery/demos/bokeh/eeg_viewer': 'gallery/demos/bokeh/multichannel_timeseries_viewer', +} + nbsite_gallery_conf = { 'backends': ['bokeh', 'matplotlib', 'plotly'], 'galleries': {}, diff --git a/examples/gallery/demos/bokeh/eeg_viewer.ipynb b/examples/gallery/demos/bokeh/eeg_viewer.ipynb deleted file mode 100644 index 52f9ed23f3..0000000000 --- a/examples/gallery/demos/bokeh/eeg_viewer.ipynb +++ /dev/null @@ -1,172 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "549b47a4", - "metadata": {}, - "source": [ - "This example demonstrates advanced visualization techniques using HoloViews with the Bokeh plotting backend. You'll learn how to:\n", - "\n", - "1. Display multiple timeseries in a single plot using `subcoordinate_y`.\n", - "2. Create and link a minimap to the main plot with `RangeToolLink`.\n", - "\n", - "Specifically, we'll simulate [Electroencephalography](https://en.wikipedia.org/wiki/Electroencephalography) (EEG) data, plot it, and then create a minimap based on the [z-score](https://en.wikipedia.org/wiki/Standard_score) of the data for easier navigation." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "8109537b-5fba-4f07-aba4-91a56f7e95c7", - "metadata": {}, - "outputs": [], - "source": [ - "import numpy as np\n", - "import holoviews as hv\n", - "from bokeh.models import HoverTool\n", - "from holoviews.plotting.links import RangeToolLink\n", - "from scipy.stats import zscore\n", - "\n", - "hv.extension('bokeh')" - ] - }, - { - "cell_type": "markdown", - "id": "1c95f241-2314-42b0-b6cb-2c0baf332686", - "metadata": {}, - "source": [ - "## Generating EEG data\n", - "\n", - "Let's start by simulating some EEG data. We'll create a timeseries for each channel using sine waves with varying frequencies." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "5f4a9dbe", - "metadata": {}, - "outputs": [], - "source": [ - "N_CHANNELS = 10\n", - "N_SECONDS = 5\n", - "SAMPLING_RATE = 200\n", - "INIT_FREQ = 2 # Initial frequency in Hz\n", - "FREQ_INC = 5 # Frequency increment\n", - "AMPLITUDE = 1\n", - "\n", - "# Generate time and channel labels\n", - "total_samples = N_SECONDS * SAMPLING_RATE\n", - "time = np.linspace(0, N_SECONDS, total_samples)\n", - "channels = [f'EEG {i}' for i in range(N_CHANNELS)]\n", - "\n", - "# Generate sine wave data\n", - "data = np.array([AMPLITUDE * np.sin(2 * np.pi * (INIT_FREQ + i * FREQ_INC) * time)\n", - " for i in range(N_CHANNELS)])" - ] - }, - { - "cell_type": "markdown", - "id": "ec9e71b8-a995-4c0f-bdbb-5d148d8fa138", - "metadata": {}, - "source": [ - "## Visualizing EEG Data\n", - "\n", - "Next, let's dive into visualizing the EEG data. We construct each timeseries using a `Curve` element, assigning it a `label` and setting `subcoordinate_y=True`. All these curves are then aggregated into a list, which serves as the input for an `Overlay` element. Rendering this `Overlay` produces a plot where the timeseries are stacked vertically.\n", - "\n", - "Additionally, we'll enhance user interaction by implementing a custom hover tool. This will display key information—channel, time, and amplitude—when you hover over any of the curves." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "9476769f-3935-4236-b010-1511d1a1e77f", - "metadata": {}, - "outputs": [], - "source": [ - "hover = HoverTool(tooltips=[\n", - " (\"Channel\", \"@channel\"),\n", - " (\"Time\", \"$x s\"),\n", - " (\"Amplitude\", \"$y µV\")\n", - "])\n", - "\n", - "channel_curves = []\n", - "for channel, channel_data in zip(channels, data):\n", - " ds = hv.Dataset((time, channel_data, channel), [\"Time\", \"Amplitude\", \"channel\"])\n", - " curve = hv.Curve(ds, \"Time\", [\"Amplitude\", \"channel\"], label=channel)\n", - " curve.opts(\n", - " subcoordinate_y=True, color=\"black\", line_width=1, tools=[hover],\n", - " )\n", - " channel_curves.append(curve)\n", - "\n", - "eeg = hv.Overlay(channel_curves, kdims=\"Channel\").opts(\n", - " xlabel=\"Time (s)\", ylabel=\"Channel\", show_legend=False, aspect=3, responsive=True,\n", - ")\n", - "eeg" - ] - }, - { - "cell_type": "markdown", - "id": "b4f603e2-039d-421a-ba9a-ed9e77efab99", - "metadata": {}, - "source": [ - "## Creating the Minimap\n", - "\n", - "A minimap can provide a quick overview of the data and help you navigate through it. We'll compute the z-score for each channel and represent it as an image; the z-score will normalize the data and bring out the patterns more clearly. To enable linking in the next step between the EEG `Overlay` and the minimap `Image`, we ensure they share the same y-axis range." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "40fa2198-c3b5-41e1-944f-f8b812612168", - "metadata": {}, - "outputs": [], - "source": [ - "y_positions = range(N_CHANNELS)\n", - "yticks = [(i , ich) for i, ich in enumerate(channels)]\n", - "\n", - "z_data = zscore(data, axis=1)\n", - "\n", - "minimap = hv.Image((time, y_positions , z_data), [\"Time (s)\", \"Channel\"], \"Amplitude (uV)\")\n", - "minimap = minimap.opts(\n", - " cmap=\"RdBu_r\", xlabel='Time (s)', alpha=.5, yticks=[yticks[0], yticks[-1]],\n", - " height=150, responsive=True, default_tools=[], clim=(-z_data.std(), z_data.std())\n", - ")\n", - "minimap" - ] - }, - { - "cell_type": "markdown", - "id": "a5b77970-342f-4428-bd1c-4dbef1e6a2b5", - "metadata": {}, - "source": [ - "## Building the dashboard\n", - "\n", - "Finally, we use [`RangeToolLink`](../../../user_guide/Linking_Plots.ipynb) to connect the minimap `Image` and the EEG `Overlay`, setting bounds for the initially viewable area with `boundsx` and `boundsy`, and finally a max range of 2 seconds with `intervalsx`. Once the plots are linked and assembled into a unified dashboard, you can interact with it. Experiment by dragging the selection box on the minimap or resizing it by clicking and dragging its edges." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "260489eb-2dbf-4c88-ba83-dd1cba0e547b", - "metadata": {}, - "outputs": [], - "source": [ - "RangeToolLink(\n", - " minimap, eeg, axes=[\"x\", \"y\"],\n", - " boundsx=(None, 2), boundsy=(None, 6.5),\n", - " intervalsx=(None, 2),\n", - ")\n", - "\n", - "dashboard = (eeg + minimap).opts(merge_tools=False).cols(1)\n", - "dashboard" - ] - } - ], - "metadata": { - "language_info": { - "name": "python", - "pygments_lexer": "ipython3" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/examples/gallery/demos/bokeh/multichannel_timeseries_viewer.ipynb b/examples/gallery/demos/bokeh/multichannel_timeseries_viewer.ipynb new file mode 100644 index 0000000000..b06a8d4d09 --- /dev/null +++ b/examples/gallery/demos/bokeh/multichannel_timeseries_viewer.ipynb @@ -0,0 +1,238 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "549b47a4", + "metadata": {}, + "source": [ + "This example demonstrates advanced visualization techniques using HoloViews with the Bokeh plotting backend. You'll learn how to:\n", + "\n", + "1. Display multiple timeseries from different data groups in a single plot using `subcoordinate_y`.\n", + "2. Normalize the timeseries per data group.\n", + "3. Create and link a minimap to the main plot with `RangeToolLink`.\n", + "\n", + "Specifically, we'll simulate [Electroencephalography](https://en.wikipedia.org/wiki/Electroencephalography) (EEG) and position data, plot it, and then create a minimap based on the [z-score](https://en.wikipedia.org/wiki/Standard_score) of the data for easier navigation." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8109537b-5fba-4f07-aba4-91a56f7e95c7", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "import holoviews as hv\n", + "from bokeh.models import HoverTool\n", + "from holoviews.operation.normalization import subcoordinate_group_ranges\n", + "from holoviews.operation.datashader import rasterize\n", + "from holoviews.plotting.links import RangeToolLink\n", + "from scipy.stats import zscore\n", + "import colorcet as cc\n", + "\n", + "hv.extension('bokeh')" + ] + }, + { + "cell_type": "markdown", + "id": "1c95f241-2314-42b0-b6cb-2c0baf332686", + "metadata": {}, + "source": [ + "## Generating data\n", + "\n", + "Let's start by `EEG` and position (`POS`) data. We'll create a timeseries for each EEG channel using sine waves with varying frequencies, and random data for three position channels. We'll set these two data groups to have different amplitudes and units." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6ac3812a", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "import pandas as pd\n", + "import holoviews as hv\n", + "from holoviews.operation.normalization import subcoordinate_group_ranges\n", + "from holoviews.plotting.links import RangeToolLink\n", + "from scipy.stats import zscore\n", + "\n", + "hv.extension('bokeh')\n", + "\n", + "GROUP_EEG = 'EEG'\n", + "GROUP_POS = 'Position'\n", + "N_CHANNELS_EEG = 10\n", + "N_CHANNELS_POS = 3\n", + "N_SECONDS = 5\n", + "SAMPLING_RATE_EEG = 200\n", + "SAMPLING_RATE_POS = 25\n", + "INIT_FREQ = 2 # Initial frequency in Hz\n", + "FREQ_INC = 5 # Frequency increment\n", + "AMPLITUDE_EEG = 1000 # EEG amplitude multiplier\n", + "AMPLITUDE_POS = 2 # Position amplitude multiplier\n", + "\n", + "# Generate time for EEG and position data\n", + "total_samples_eeg = N_SECONDS * SAMPLING_RATE_EEG\n", + "total_samples_pos = N_SECONDS * SAMPLING_RATE_POS\n", + "time_eeg = np.linspace(0, N_SECONDS, total_samples_eeg)\n", + "time_pos = np.linspace(0, N_SECONDS, total_samples_pos)\n", + "\n", + "# Generate EEG timeseries data\n", + "def generate_eeg_data(index):\n", + " return AMPLITUDE_EEG * np.sin(2 * np.pi * (INIT_FREQ + index * FREQ_INC) * time_eeg)\n", + "\n", + "eeg_channels = [str(i) for i in np.arange(N_CHANNELS_EEG)]\n", + "eeg_data = np.array([generate_eeg_data(i) for i in np.arange(N_CHANNELS_EEG)])\n", + "eeg_df = pd.DataFrame(eeg_data.T, index=time_eeg, columns=eeg_channels)\n", + "eeg_df.index.name = 'Time'\n", + "\n", + "# Generate position data\n", + "pos_channels = ['X', 'Y', 'Z'] # avoid lowercase 'x' and 'y' as channel/dimension names\n", + "pos_data = AMPLITUDE_POS * np.random.randn(N_CHANNELS_POS, total_samples_pos).cumsum(axis=1)\n", + "pos_df = pd.DataFrame(pos_data.T, index=time_pos, columns=pos_channels)\n", + "pos_df.index.name = 'Time'" + ] + }, + { + "cell_type": "markdown", + "id": "ec9e71b8-a995-4c0f-bdbb-5d148d8fa138", + "metadata": {}, + "source": [ + "## Visualizing EEG Data\n", + "\n", + "Next, let's dive into visualizing the data. We construct each timeseries using a `Curve` element, assigning it a `group`, a `label` and setting `subcoordinate_y=True`. All these curves are then aggregated into a list per data group, which serves as the input for an `Overlay` element. Rendering this `Overlay` produces a plot where the timeseries are stacked vertically.\n", + "\n", + "Additionally, we'll enhance user interaction by implementing a custom hover tool. This will display key information about the group, channel, time, and amplitude value when you hover over any of the curves." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9476769f-3935-4236-b010-1511d1a1e77f", + "metadata": {}, + "outputs": [], + "source": [ + "# Create a Curve per data series\n", + "def df_to_curves(df, kdim, vdim, color='black', group='EEG'):\n", + " curves = []\n", + " for i, (channel, channel_data) in enumerate(df.items()):\n", + " ds = hv.Dataset((channel_data.index, channel_data), [kdim, vdim])\n", + " curve = hv.Curve(ds, kdim, vdim, group=group, label=str(channel))\n", + " curve.opts(\n", + " subcoordinate_y=True, color=color if isinstance(color, str) else color[i], line_width=1, \n", + " hover_tooltips=hover_tooltips, tools=['xwheel_zoom'], line_alpha=.8,\n", + " )\n", + " curves.append(curve)\n", + " return curves\n", + "\n", + "hover_tooltips = [(\"Group\", \"$group\"), (\"Channel\", \"$label\"), (\"Time\"), (\"Value\")]\n", + "\n", + "vdim_EEG = hv.Dimension(\"Value\", unit=\"µV\")\n", + "vdim_POS = hv.Dimension(\"Value\", unit=\"cm\")\n", + "time_dim = hv.Dimension(\"Time\", unit=\"s\")\n", + "\n", + "eeg_curves = df_to_curves(eeg_df, time_dim, vdim_EEG, color='black', group='EEG')\n", + "pos_curves = df_to_curves(pos_df, time_dim, vdim_POS, color=cc.glasbey_cool, group='POS')\n", + "\n", + "# Combine EEG and POS curves into an Overlay\n", + "eeg_curves_overlay = hv.Overlay(eeg_curves, kdims=\"Channel\")\n", + "pos_curves_overlay = hv.Overlay(pos_curves, kdims=\"Channel\")\n", + "curves_overlay = (eeg_curves_overlay * pos_curves_overlay).opts(\n", + " xlabel=time_dim.pprint_label, ylabel=\"Channel\", show_legend=False, aspect=3, responsive=True,\n", + ")\n", + "curves_overlay" + ] + }, + { + "cell_type": "markdown", + "id": "983e1f84-6006-4d64-9144-4aba0ad93946", + "metadata": {}, + "source": [ + "Note that the overlay above has multiple wheel-zoom tools in the toolbar, you can hover over the icons in the toolbar to reveal each of the first two control the Y-axis zoom of their respective data group within each curve's subcoordinate range, and the third wheel zoom tool controls the X-axis scale of all the curves together.\n", + "\n", + "By default, all the curves, including across data groups, have the same y-axis range that is computed from the min and max across all channels. As a consequence, the position curves in blue, which have a much smaller amplitude than timeseries in the EEG data group, appear to be quite flat and are hard to inspect. To deal with this situation, we can transform the *Overlay* with the `subcoordinate_group_ranges` operation that will apply a min-max normalization of the timeseries per group." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2bb78a48-5c6a-4969-bf58-539fce784364", + "metadata": {}, + "outputs": [], + "source": [ + "# Apply group-wise normalization\n", + "normalized_overlay = subcoordinate_group_ranges(curves_overlay)\n", + "normalized_overlay" + ] + }, + { + "cell_type": "markdown", + "id": "b4f603e2-039d-421a-ba9a-ed9e77efab99", + "metadata": {}, + "source": [ + "## Creating the Minimap\n", + "\n", + "A minimap can provide a quick overview of the data and help you navigate through it. We'll compute the z-score for each channel and represent it as an image; the z-score will normalize the data and bring out the patterns more clearly. To enable linking in the next step between the timeseries `Overlay` and the minimap `Image`, we ensure they share the same y-axis range. We will also leverage rasterization in case the full image resolution is too large to render on the screen." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "40fa2198-c3b5-41e1-944f-f8b812612168", + "metadata": {}, + "outputs": [], + "source": [ + "y_positions = range(N_CHANNELS_EEG + N_CHANNELS_POS)\n", + "\n", + "# Reindex the lower frequency DataFrame to match the higher frequency index\n", + "pos_df_interp = pos_df.reindex(eeg_df.index).interpolate(method='index')\n", + "\n", + "# concatenate the EEG and interpolated POS data and z-score the full data array\n", + "z_data = zscore(np.concatenate((eeg_df.values, pos_df_interp.values), axis=1), axis=0).T\n", + "\n", + "minimap = rasterize(hv.Image((time_eeg, y_positions , z_data), [time_dim, \"Channel\"], \"Value\"))\n", + "minimap = minimap.opts(\n", + " cmap=\"RdBu_r\", xlabel='', alpha=.7,\n", + " yticks=[(y_positions[0], f'EEG {eeg_channels[0]}'), (y_positions[-1], f'POS {pos_channels[-1]}')],\n", + " height=120, responsive=True, toolbar='disable', cnorm='eq_hist'\n", + ")\n", + "minimap" + ] + }, + { + "cell_type": "markdown", + "id": "a5b77970-342f-4428-bd1c-4dbef1e6a2b5", + "metadata": {}, + "source": [ + "## Building the dashboard\n", + "\n", + "Finally, we use [`RangeToolLink`](../../../user_guide/Linking_Plots.ipynb) to connect the minimap `Image` and the timeseries `Overlay`, setting bounds for the initially viewable area with `boundsx` and `boundsy`, and finally demonstrate setting an upper max zoom range of 3 seconds with `intervalsx`. Once the plots are linked and assembled into a unified dashboard, you can interact with it. Experiment by dragging the selection box on the minimap or resizing it by clicking and dragging its edges." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "260489eb-2dbf-4c88-ba83-dd1cba0e547b", + "metadata": {}, + "outputs": [], + "source": [ + "RangeToolLink(\n", + " minimap, normalized_overlay, axes=[\"x\", \"y\"],\n", + " boundsx=(.5, 3), boundsy=(1.5, 12.5),\n", + " intervalsx=(None, 3),\n", + ")\n", + "\n", + "dashboard = (normalized_overlay + minimap).cols(1).opts(shared_axes=False)\n", + "dashboard" + ] + } + ], + "metadata": { + "language_info": { + "name": "python", + "pygments_lexer": "ipython3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +}