Multichannel Timeseries#

hvplotpanelholoviewsdatashaderholonote
Published: November 20, 2024


Multichannel Timeseries Workflow


Hide code cell source
from IPython.display import HTML
HTML("""
<div style="display: flex; justify-content: center; padding: 10px;">
    <iframe width="560" height="315" src="https://www.youtube.com/embed/8oeuPptWPt8?si=yh-4iJYDfYpJZ7MQ" title="YouTube video player" frameborder="0" allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
</div>
""")

Prerequisites#

What?

Why?

Index: Intro, Workflows, Extensions

For context and workflow selection/feature guidance

Overview#

For an introduction, please visit the ‘Index’ page. This workflow is tailored for processing and analyzing ‘medium-sized’ multichannel timeseries data derived from electrophysiological recordings.

What Defines a ‘Medium-Sized’ Dataset?#

In this context, we’ll define a medium-sized dataset as that which is challenging for browsers (roughly more than 100,000 samples) but can be handled within the available RAM without exhausting system resources.

Why Downsample?#

Medium-sized datasets can strain the processing capabilities when visualizing or analyzing data directly in the browser. To address this challenge, we will employ a smart-downsampling approach - reducing the dataset size by selectively subsampling the data points. Specifically, we’ll make use of a variant of a downsampling algorithm called Largest Triangle Three Buckets (LTTB). LTTB allows data points not contributing significantly to the visible shape to be dropped, reducing the amount of data to send to the browser but preserving the appearance (and particularly the envelope, i.e. highest and lowest values in a region). This ensures efficient data handling and visualization without significant loss of information.

Downsampling is particularly beneficial when dealing with numerous timeseries sharing a common time index, as it allows for a consolidated slicing operation across all series, significantly reducing the computational load and enhancing responsiveness for interactive visualization. We’ll make use of a Pandas index to represent the time index across all timeseries.

Quick Introduction to MNE#

MNE (MNE-Python) is a powerful open-source Python library designed for handling and analyzing data like EEG and MEG. In this workflow, we’ll utilize an EEG dataset, so we demonstrate how to use MNE for loading, preprocessing, and conversion to a Pandas DataFrame. However, the data visualization section is highly generalizable to dataset types beyond the scope of MNE, so you can meet us there if you have your timeseries data as a Pandas DataFrame with a time index and channel columns.


Imports and Configuration#

from pathlib import Path

import holoviews as hv
import mne
import pandas as pd
import panel as pn
import pooch
from holoviews.operation.downsample import downsample1d

pn.extension('tabulator')
hv.extension('bokeh')

Loading and Inspecting the Data#

Let’s get some data! This section walks through obtaining an EEG dataset (2.6 MB).

DATA_URL = 'https://datasets.holoviz.org/eeg/v1/S001R04.edf'
DATA_DIR = Path('./data')
DATA_FILENAME = Path(DATA_URL).name
DATA_PATH = DATA_DIR / DATA_FILENAME

Note

If you are viewing this notebook as a result of using the `anaconda-project run` command, the data has already been ingested, as configured in the associated yaml file. Running the following cell should find that data and skip any further download.
DATA_DIR.mkdir(parents=True, exist_ok=True)

# Download the data if it doesn't exist
if not DATA_PATH.exists():
    print(f'Downloading data to: {DATA_PATH}')
    pooch.retrieve(
        url=DATA_URL,
        known_hash=None,
        fname=DATA_FILENAME,
        path=DATA_DIR,
        progressbar=True,
    )
else:
    print(f'Data exists at: {DATA_PATH}')
Data exists at: data/S001R04.edf

Once we have a local copy of the data, the next crucial step is to load it into an analysis-friendly format and inspect its basic characteristics:

raw = mne.io.read_raw_edf(DATA_PATH, preload=True)
print('num samples in dataset:', len(raw.times) * len(raw.ch_names))
raw.info
Extracting EDF parameters from /home/runner/work/examples/examples/multichannel_timeseries/data/S001R04.edf...
EDF file detected
Setting channel info structure...
Creating raw.info structure...
Reading 0 ... 19999  =      0.000 ...   124.994 secs...
num samples in dataset: 1280000
General
MNE object type Info
Measurement date 2009-08-12 at 16:15:00 UTC
Participant X
Experimenter Unknown
Acquisition
Sampling frequency 160.00 Hz
Channels
EEG
Head & sensor digitization Not available
Filters
Highpass 0.00 Hz
Lowpass 80.00 Hz

This step confirms the successful loading of the data and provides an initial understanding of its structure, such as the number of channels and samples.

Now, let’s preview the channel names, types, unit, and signal ranges. This describe method is from MNE, and we can have it return a Pandas DataFrame, from which we can sample some rows.

raw.describe(data_frame=True).sample(5)
name type unit min Q1 median Q3 max
ch
42 T9.. eeg V -0.000161 -0.000033 -0.000002 0.000029 0.000206
6 Fc6. eeg V -0.000159 -0.000026 0.000001 0.000027 0.000176
14 Cp5. eeg V -0.000191 -0.000034 -0.000002 0.000031 0.000192
29 F7.. eeg V -0.000323 -0.000041 -0.000003 0.000037 0.000331
19 Cp4. eeg V -0.000186 -0.000034 -0.000002 0.000029 0.000180

Pre-processing the Data#

Noise Reduction via Averaging#

Significant noise reduction is often achieved by employing an average reference, which involves calculating the mean signal across all channels at each time point and subtracting it from the individual channel signals:

raw.set_eeg_reference("average")
EEG channel type selected for re-referencing
Applying average reference.
Applying a custom ('EEG',) reference.
General
Filename(s) S001R04.edf
MNE object type RawEDF
Measurement date 2009-08-12 at 16:15:00 UTC
Participant X
Experimenter Unknown
Acquisition
Duration 00:02:05 (HH:MM:SS)
Sampling frequency 160.00 Hz
Time points 20,000
Channels
EEG
Head & sensor digitization Not available
Filters
Highpass 0.00 Hz
Lowpass 80.00 Hz

Standardizing Channel Names#

From the output of the describe method, it looks like the channels are from commonly used standardized locations (e.g. ‘Cz’), but contain some unnecessary periods, so let’s clean those up to ensure smoother processing and analysis.

raw.rename_channels(lambda s: s.strip("."));

Optional: Enhancing Channel Metadata#

Visualizing physical locations of EEG channels enhances interpretative analysis. MNE has functionality to assign locations of the channels based on their standardized channel names, so we can go ahead and assign a commonly used arrangement (or ‘montage’) of electrodes (‘10-05’) to this data. Read more about making and setting the montage here.

montage = mne.channels.make_standard_montage("standard_1005")
raw.set_montage(montage, match_case=False)
General
Filename(s) S001R04.edf
MNE object type RawEDF
Measurement date 2009-08-12 at 16:15:00 UTC
Participant X
Experimenter Unknown
Acquisition
Duration 00:02:05 (HH:MM:SS)
Sampling frequency 160.00 Hz
Time points 20,000
Channels
EEG
Head & sensor digitization 67 points
Filters
Highpass 0.00 Hz
Lowpass 80.00 Hz

We can see that the ‘digitized points’ (locations) are now added to the raw data.

Now let’s plot the channels using MNE plot_sensors on a top-down view of a head. Note, we’ll tweak the reference point so that all the points are contained within the depiction of the head.

sphere=(0, 0.015, 0, 0.099) # manually adjust the y origin coordinate and radius
raw.plot_sensors(show_names=True, sphere=sphere, show=False);
../../_images/07bb002667a14517229247584439eb8a7b762e6f038713d064ade8db70b84ef9.png

Data Visualization#

Preparing Data for Visualization#

We’ll use an MNE method, to_data_frame, to create a Pandas DataFrame. By default, MNE will convert EEG data from Volts to microVolts (µV) during this operation.

df = raw.to_data_frame()
df.set_index('time', inplace=True) 
df.head()
Fc5 Fc3 Fc1 Fcz Fc2 Fc4 Fc6 C5 C3 C1 ... P8 Po7 Po3 Poz Po4 Po8 O1 Oz O2 Iz
time
0.00000 8.703125 15.703125 50.703125 52.703125 43.703125 39.703125 -2.296875 -0.296875 17.703125 31.703125 ... -7.296875 5.703125 -21.296875 -31.296875 -52.296875 -25.296875 -19.296875 -34.296875 -25.296875 -25.296875
0.00625 46.062500 34.062500 59.062500 56.062500 43.062500 36.062500 3.062500 22.062500 31.062500 33.062500 ... 8.062500 18.062500 -9.937500 -6.937500 -25.937500 6.062500 37.062500 16.062500 27.062500 24.062500
0.01250 -13.656250 -14.656250 4.343750 -1.656250 0.343750 8.343750 -3.656250 -24.656250 -7.656250 -1.656250 ... 46.343750 41.343750 13.343750 28.343750 15.343750 45.343750 43.343750 21.343750 34.343750 36.343750
0.01875 -3.015625 -4.015625 12.984375 -2.015625 2.984375 7.984375 -5.015625 0.984375 9.984375 8.984375 ... 23.984375 2.984375 -15.015625 -1.015625 -5.015625 21.984375 18.984375 0.984375 28.984375 19.984375
0.02500 20.656250 10.656250 32.656250 12.656250 11.656250 2.656250 -17.343750 13.656250 15.656250 12.656250 ... 21.656250 10.656250 -4.343750 11.656250 2.656250 28.656250 5.656250 -4.343750 31.656250 20.656250

5 rows × 64 columns

Creating the Multichannel Timeseries Plot#

As of the time of writing, there’s no easy way to track units with Pandas, so we can use a modular HoloViews approach to create and annotate dimensions with a unit, and then refer to these dimensions when plotting. Read more about annotating data with HoloViews here.

time_dim = hv.Dimension("time", unit="s") # match the df index name, 'time'

Now we will loop over the columns (channels) in the dataframe, creating a HoloViews Curve element from each. Since each column in the df has a different channel name, which is generally not describing a measurable quantity, we will map from the channel to a common amplitude dimension (see this issue for details of this enhancement for ‘wide’ tabular data), and collect each Curve element into a Python dictionary.

In configuring these curves, we apply the .opts method from HoloViews to fine-tune the visualization properties of each curve. The subcoordinate_y setting is pivotal for managing time-aligned, amplitude-diverse plots. When enabled, it arranges each curve along its own segment of the y-axis within a single composite plot. This method not only aids in differentiating the data visually but also in analyzing comparative trends across multiple channels, ensuring that each channel’s data is individually accessible and comparably presentable, thereby enhancing the analytical value of the visualizations. Applying subcoordinate_y has additional effects, such as creating a Y-axis zoom tool that applies to individual subcoordinate axes rather than the global Y-axis. Read more about subcoordinate_y here.

curves = {}
for col in df.columns:
    col_amplitude_dim = hv.Dimension(col, label='amplitude', unit="µV") # map amplitude-labeled dim per chan
    curves[col] = hv.Curve(df, time_dim, col_amplitude_dim, group='EEG', label=col)
    # Apply options
    curves[col] = curves[col].opts(
        subcoordinate_y=True, # Essential to create vertically stacked plot
        subcoordinate_scale=3,
        color="black",
        line_width=1,
        hover_tooltips = [
            ("type", "$group"),
            ("channel", "$label"),
            ("time"),
            ("amplitude")],
        tools=['xwheel_zoom'],
        active_tools=["box_zoom"]
    )

Using a HoloViews container, we can now overlay all the curves on the same plot.

curves_overlay = hv.Overlay(curves, kdims=[time_dim, 'Channel'])

overlay_opts = dict(ylabel="Channel",
    show_legend=False,
    padding=0,
    min_height=600,
    responsive=True,
    title="",
)

curves_overlay = curves_overlay.opts(**overlay_opts)

Apply Downsampling#

Since there are dozens of channels and over a million data samples, we’ll make use of downsampling before trying to send all that data to the browser. We can use downsample1d imported from HoloViews. Starting in HoloViews version 1.19.0, integration with the tsdownsample library introduces enhanced downsampling algorithms. Read more about downsampling here.

curves_overlay_lttb = downsample1d(curves_overlay, algorithm='minmax-lttb')

Display Multichannel Timeseries Plot#

It is recommended to only have a single rendered instance of the same application component in a notebook, so we’ve commented out the following cell so we can demonstrate additional extensions below. But feel free to uncomment and display the following if you don’t want any further extensions.

# curves_overlay_lttb

Static Preview No Extensions

Here’s a static snapshot of what the previous cell produces (a DynamicMap of downsampled and overlaid curve subplots). 👉

print(curves_overlay_lttb)
:DynamicMap   []


Minimap Extension#

To assist in navigating the dataset, we integrate a minimap widget. This secondary minimap plot provides a condensed overview of the entire dataset, allowing users to select and zoom into areas of interest quickly in the main plot while maintaining the contextualization of the zoomed out view.

We will employ datashader rasterization of the image for the minimap plot to display a browser-friendly, aggregated view of the entire dataset. Read more about datashder rasterization via HoloViews here.

from scipy.stats import zscore
from holoviews.operation.datashader import rasterize
from holoviews.plotting.links import RangeToolLink

channels = df.columns
time = df.index.values

y_positions = range(len(channels))
yticks = [(i, ich) for i, ich in enumerate(channels)]
z_data = zscore(df, axis=0).T
minimap = rasterize(hv.Image((time, y_positions, z_data), [time_dim, "Channel"], "amplitude"))

minimap_opts = dict(
    cmap="RdBu_r",
    colorbar=False,
    xlabel='',
    alpha=0.5,
    yticks=[yticks[0], yticks[-1]],
    toolbar='disable',
    height=120,
    responsive=True,
    cnorm='eq_hist',
    )

minimap = minimap.opts(**minimap_opts)

The connection between the main plot and the minimap is facilitated by a RangeToolLink, enhancing user interaction by synchronizing the visible range of the main plot with selections made on the minimap. Optionally, we’ll also constrain the initially displayed x-range view to a third of the duration.

RangeToolLink(minimap, curves_overlay_lttb, axes=["x", "y"],
              boundsx=(0, time[len(time)//3]), # limit the initial selected x-range of the minimap
              boundsy=(-.5,len(channels)//3), # limit the initial selected y-range of the minimap
              use_handles=False, # Optionally, disable handles on the RangeTool box selection
             )
RangeToolLink(axes=['x', 'y'], boundsx=(0, np.float64(41.6625)), boundsy=(-0.5, 21), intervalsx=None, intervalsy=None, inverted=True, name='RangeToolLink00869', start_gesture='tap', use_handles=False)

Finally, we’ll arrange the main plot and minimap into a single column layout.

app = (curves_overlay_lttb + minimap).opts(shared_axes=False).cols(1)

It is recommended to only have a single rendered instance of the same application component in a notebook, so we’ve commented out the following cell so we can demonstrate additional extensions below. But feel free to uncomment and display the following if you don’t want any further extensions.

# app

Static Preview Minimap

Here’s a static snapshot of what the previous cell produces (a layout of two DynamicMap elements - the downsampled curves plot and a rasterized minimap plot). 👉

# print(app)

Scale Bar Extension#

While the hover tooltips in our plot allow us to inspect amplitude values at specific points, sometimes it’s beneficial to have a persistent visual reference for amplitude measurements. Adding a scale bar provides a constant guide to the amplitude scale, enhancing the interpretability of our visualization, especially when comparing signal amplitudes across different channels or time windows.

In HoloViews, we can incorporate a scale bar into any curve element. This feature displays a bar that represents a specific amplitude value, which can be toggled on or off using the measurement ruler icon in the plot’s toolbar. This allows users to choose whether they want the scale bar visible.

For our application, we’ll add a scale bar to one of the EEG channels—specifically, channel 'C1'. We’ll customize the scale bar’s appearance and behavior using .opts, ensuring it aligns with the units of our data (microvolts, µV). The scalebar_opts dictionary allows us to fine-tune the visual aspects of the scale bar, such as line width, label location, background color, and whether the scale bar size adapts when zooming. See more options in the Bokeh docs.

curves['C1'].opts(
        scalebar=True,
        scalebar_location="right",
        scalebar_unit=('µV', 'V'),
        scalebar_opts={
            'bar_line_width':3,
            'label_location':'left',
            'background_fill_color':'white',
            'background_fill_alpha':.6,
            'length_sizing':'adaptive',
            'bar_length_units': 'data',
            'bar_length':.8,
        }
); # add semicolon to prevent display of output

Uncomment and display the following if you don’t want any further extensions.

app

Static Preview Scalebar

Here’s a static snapshot of what the previous cell produces (the previous layout with the addition of a scalebar for one of the curves). 👉

The pink arrow annotation indicates the newly added adaptive scale bar, and the green arrow points to the toolbar icon used to toggle its display.

# print(app)

As you interact with the plot by zooming in and out, you’ll notice that the scale bar adapts to the current zoom level. This dynamic adjustment ensures that the scale bar remains a relevant reference regardless of the level of detail you’re viewing.

Time Range Annotation Extension#

In this extension, we’ll enhance our multichannel timeseries visualization by adding interactive time range annotations. This feature allows us to mark and label specific periods within the data, which can be invaluable for highlighting events, artifacts, or regions of interest during analysis.

We’ll utilize the HoloNote package, which extends HoloViews with tools for annotating plots and integrating them into interactive dashboards.

Preparing Annotations Data#

If you don’t have any preexisting annotations, that’s perfectly fine. You can proceed without an initial DataFrame and use HoloNote to create annotations from scratch interactively within your visualization. In this case, you can initialize an empty DataFrame or skip the DataFrame preparation step.

However, if you do have preexisting annotations, either from a separate file or embedded in the dataset, read them into a Pandas DataFrame as we do below and adjust the code accordingly to fit your data structure. We’ll demonstrate how to extract annotations that are embedded in our EEG demo dataset (See men.Annotations for information on handling these annotations):

if any(raw.annotations):
    # Get the initial time of the experiment
    orig_time = raw.annotations.orig_time
    
    # Convert the annotations to a DataFrame
    annotations_df = raw.annotations.to_data_frame()
    
    # Ensure the 'onset' column is in UTC timezone
    annotations_df['onset'] = annotations_df['onset'].dt.tz_localize('UTC')
    
    # Create 'start' and 'end' columns in seconds relative to the experiment start
    annotations_df['start'] = (annotations_df['onset'] - orig_time).dt.total_seconds()
    annotations_df['end'] = annotations_df['start'] + annotations_df['duration']
    
    # Preview the annotations DataFrame
    annotations_df.head()
else:
    # If no annotations are included in our dataset, initialize with an empty data
    annotations_df = pd.DataFrame(columns=['start', 'end'])

Setting Up the Annotator#

Now we’ll configure the Annotator object from HoloNote, which manages the annotations and integrates them with our plots.

In this demo, we’re using a connector to an in-memory SQLite database by specifying SQLiteDB(filename=':memory:') for temporary storage of annotations:

from holonote.annotate import Annotator
from holonote.app import PanelWidgets, AnnotatorTable
from holonote.annotate.connector import SQLiteDB

annotator = Annotator(
    {'time': float}, # Indicates that we are working with time-based annotations
    fields=["description"],
    groupby = "description", # Group annotations to enable color-coding and batch actions
    connector=SQLiteDB(filename=':memory:'),
)

Note on Persistent Storage: If we don’t specify a connector, HoloNote will by default create a persistent SQLite database file named ‘annotations.db’ in the current directory. You can also specify a custom filename in place of ':memory:' to store annotations persistently in a specific file, such as SQLiteDB(filename='my_annotations.db').

When working in a real workflow, you’ll likely want persistent storage to save your annotations between sessions. Be aware that if a file with the specified name already exists, HoloNote will use it, and any changes committed through the UI will modify this file. This allows you to maintain and update your annotations across different sessions, ensuring your work is saved and accessible later.

Defining Annotations#

We now populate the Annotator with the annotations from our DataFrame. If you have an existing annotations_df, whether extracted from the dataset or loaded from a file, you can define the annotations as follows:

annotator.define_annotations(annotations_df, time=("start", "end"))

This line tells the annotator to use the ‘start’ and ‘end’ columns from our DataFrame as the time ranges for the annotations.

If you started without any existing annotations, you can skip this step, and the Annotator will start without any annotations, ready for you to add new ones interactively.

Configuring Annotation Widgets#

To enable interaction with the annotations, we’ll create widgets that allow users to view and manage them.

annotator.visible = ["T1", "T2"]  # Optional, specify which to initially display

annotator_widgets = pn.WidgetBox(
    PanelWidgets(annotator),
    AnnotatorTable(
        annotator,
        tabulator_kwargs=dict(
            pagination="local",
            page_size=10,
            sizing_mode="stretch_both",
            layout="fit_columns",
            theme="default",
            stylesheets=[":host .tabulator {font-size: 12px;}"],
            text_align={"description": "center"},
        ),
    ),
    max_width=325,
)

The above code creates a WidgetBox containing:

  • Annotation controls (PanelWidgets) for adding, editing, or deleting annotations. Since we grouped the annotations, PanelWidgets will also include a ‘Visible’ widget to toggle visibility by group.

  • An AnnotatorTable to display annotations in a tabular format.

We also customize the table’s appearance and functionality using tabulator_kwargs, adjusting pagination, sizing, layout, theme, style, and text alignment. Check our the Panel Tabulator docs for more options.

Integrating Annotations with the Plots#

Finally, we’ll overlay the annotations onto our existing multichannel timeseries plot and the minimap. This integration allows us to see the annotations directly on the plots and interact with them.

Add new annotations by:

  • Ensure that the ‘+’ button is selected in the widgets.

  • Ensure that ‘Box select (x-axis)’ is selected in the Bokeh Toolbar.

  • Enter the name of the group that you want the annotation to be added to in the ‘description’ text box.

  • Select a time range by clicking and dragging on the plot

  • Edit or delete annotations using the provided widgets and table.

Note, if you are using a persistent file on disk for the annotations, hit the triangle button to commit changes to the file.

annotated_multichan = (annotator * curves_overlay_lttb).opts(responsive=True, min_height=700)
annotated_minimap = (annotator * minimap)
annotated_app = (annotated_multichan + annotated_minimap).cols(1).opts(shared_axes=False)

⚠️ Heads up!

Viewing this on examples.holoviz.org? This is a static version of the notebook 📄. Interactive features that require a live Python process, like downsampling and datashading, won’t work here 🚫. To use these features, open the notebook with a live Python session.

pn.Row(annotator_widgets, annotated_app)

Standalone App Extension#

HoloViz Panel allows for the deployment of this complex visualization as a standalone, template-styled, interactive web application (outside of a Jupyter Notebook). Read more about Panel here.

We’ll add our plots to the main area of a Panel Template component and the widgets to the sidebar. Finally, we’ll set the entire component to be servable.

To launch the standalone app, activate the same conda environment and run panel serve <path-to-this-file> --show in the command line. This will open the application in a browser window.

Warning

It is not recommended to have both a notebook version of the application and a served of the same application running simultaneously. Prior to serving the standalone application, clear the notebook output, restart the notebook kernel, and saved the unexecuted notebook file.
servable_app = pn.template.FastListTemplate(
    sidebar = annotator_widgets,
    title = "HoloViz + Bokeh Multichannel Timeseries with Time-Range Annotator",
    main = annotated_app,
).servable()

Static Preview Multichan Standalone

Here’s a static snapshot of what the previous cell produces in a browser window when you serve the standalone app (a templated Panel application). 👉

print(servable_app)
<FastListTemplate FastListTemplate06491>

What Next?#

This web page was generated from a Jupyter notebook and not all interactivity will work on this website.