{ "cells": [ { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "import numpy as np\n", "import pandas as pd\n", "import holoviews as hv\n", "\n", "from holoviews.util.transform import dim\n", "from holoviews.selection import link_selections\n", "from holoviews.operation import gridmatrix\n", "from holoviews.operation.element import histogram\n", "from holoviews import opts\n", "\n", "hv.extension('bokeh', 'plotly', width=100)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### JavaScript-based linked brushing\n", "\n", "Datasets very often have more dimensions than can be shown in a single plot, which is why HoloViews offers so many ways to show the data from each of these dimensions at once (via layouts, overlays, grids, holomaps, etc.). However, even once the data has been displayed, it can be difficult to relate data points between the various plots that are laid out together. For instance, \"is the outlier I can see in this x,y plot the same datapoint that stands out in this w,z plot\"? \"Are the datapoints with high x values in this plot also the ones with high w values in this other plot?\" Since points are not usually visibly connected between plots, answering such questions can be difficult and tedious, making it difficult to understand multidimensional datasets. [Linked brushing](https://infovis-wiki.net/wiki/Linking_and_Brushing) (also called \"brushing and linking\") offers an easy way to understand how data points and groups of them relate across different plots. Here \"brushing\" refers to selecting data points or ranges in one plot, with \"linking\" then highlighting those same points or ranges in other plots derived from the same data.\n", "\n", "As an example, consider the standard \"autompg\" dataset:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "from bokeh.sampledata.autompg import autompg\n", "autompg" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "This dataset contains specifications for 392 different types of car models from 1970 to 1982. Each car model represents a particular point in a nine-dimensional space, with a certain **mpg**, **cyl**, **displ**, **hp**, **weight**, **accel**, **yr**, **origin**, and **name**. We can use a [gridmatrix](http://holoviews.org/gallery/demos/bokeh/iris_density_grid.html) to see how each numeric dimension relates to the others:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "autompg_ds = hv.Dataset(autompg, ['yr', 'name', 'origin'])\n", "\n", "mopts = opts.Points(size=2, tools=['box_select','lasso_select'], active_tools=['box_select'])\n", "\n", "gridmatrix(autompg_ds, chart_type=hv.Points).opts(mopts)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "These plots show all sorts of interesting relationships already, such as that weight and horsepower are highly positively correlated (locate _weight_ along one axis and _hp_ along the other, and you can see that car models with high weight almost always have high horsepower and vice versa).\n", "\n", "What if we want to focus specifically on the subset of cars that have 4 cylinders (*cyl*)? You can do that by pre-filtering the dataframe in Python, but questions like that can be answered immediately using linked brushing, which is automatically supported by `gridmatrix` plots like this one. First, make sure the \"box select\" or \"lasso select\" tool is selected in the toolbar:\n", "\n", "\n", "\n", "Then pick one of the points plots labeled _cyl_ and use the selection tool to select all the values where _cyl_ is 4. All of those points in each plot should remain blue, while the points where _cyl_ is not 4 become more transparent: " ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "You should be able to see that 4-cylinder models have low displacement (*displ*), low horsepower (*hp*), and low weight, but tend to have higher fuel efficiency (*mpg*). Repeatedly selecting subsets of the data in this way can help you understand properties of a multidimensional dataset that may not be visible in the individual plots, without requiring coding and examining additional plots." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "# Python-based linked brushing\n", "\n", "The above example illustrates Bokeh's very useful automatic JavaScript-based [linked brushing](https://docs.bokeh.org/en/latest/docs/user_guide/interaction/linking.html#linked-brushing), which can be enabled for Bokeh plots sharing a common data source (as in the `gridmatrix` call) by simply adding a selection tool. However, this approach offers only a single type of selection, and is not available for Python-based data-processing pipelines such as those using [Datashader](15-Large_Data.ipynb).\n", "\n", "To get more power and flexibility (at the cost of requiring a Python server for deployment if you weren't already), HoloViews provides a Python-based implementation of linked brushing. HoloViews linked brushing lets you fully customize what elements are used and how linking behaves. Here, let's make a custom Layout displaying some Scatter plots for just a few of the available dimensions:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "colors = hv.Cycle('Category10').values\n", "dims = [\"cyl\", \"displ\", \"hp\", \"mpg\", \"weight\", \"yr\"]\n", "\n", "layout = hv.Layout([\n", " hv.Points(autompg_ds, dims).opts(color=c)\n", " for c, dims in zip(colors, [[d,'accel'] for d in dims])\n", "])\n", "\n", "print(layout)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Now that we have a layout we can simply apply the `link_selections` operation to support linked brushing, automatically linking the selections across an arbitrary collection of plots that are derived from the same dataset:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "link_selections(layout).opts(opts.Points(width=200, height=200)).cols(6)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "The same `box_select` and `lasso_select` tools should now work as for the `gridmatrix` plot, but this time by calling back to Python. There are now many more options and capabilities available, as described below, but by default you can now also select additional regions in different elements, and the selected points will be those that match _all_ of the selections, so that you can precisely specify the data points of interest with constraints on _all_ dimensions at once. A bounding box will be shown for each selection, but only the overall selected points (across all selection dimensions) will be highlighted in each plot. You can use the reset tool to clear all the selections and start over.\n", "\n", "## Box-select vs Lasso-select\n", "\n", "Since HoloViews version 1.13.3 linked brushing supports both the `box_select` and `lasso_select` tools. The lasso selection provides more fine-grained control about the exact region to include in the selection, however it is a much more expensive operation and will not scale as well to very large columnar datasets. Additionally lasso select has a number of dependencies:\n", "\n", "* Lasso-select on tabular data requires either `spatialpandas` or `shapely`\n", "* Lasso-select on gridded data requires `datashader`\n", "* Lasso-select on geometry data requires `shapely`" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Filter and selection modes\n", "\n", "Two parameters of `link_selections` control how the selections apply within a single element (the `selection_mode`) and across elements (the `cross_filter_mode`):\n", "\n", "* `selection_mode`: Determines how to combine successive selections on the same element, either `'overwrite'` (the default, allowing one selection per element), `'intersect'` (taking the intersection of all selections for that element), `'union'` (the combination of all selections for that element), or `'inverse'` (select all _but_ the selection region).\n", "* `cross_filter_mode`: Determines how to combine selections across different elements, either `'overwrite'` (allows selecting on only a single element at a time) or `'intersect'` (the default, combining selections across all elements).\n", "\n", "To see how these work, we will create a number of views of the autompg dataset:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "w_accel_scatter = hv.Scatter(autompg_ds, 'weight', 'accel')\n", "mpg_hist = histogram(autompg_ds, dimension='mpg', normed=False).opts(color=\"green\")\n", "violin = hv.Violin(autompg_ds, [], 'hp')" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "We will also capture an \"instance\" of the `link_selections` operation, which will allow us to access and set parameters on it even after we call it:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "mpg_ls = link_selections.instance()\n", "\n", "mpg_ls(w_accel_scatter + mpg_hist + violin)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Here you can select on both the Scatter plot and the Histogram. With these default settings, selecting on different elements computes the intersection of the two selections, allowing you to e.g. select only the points with high weight but mpg between 20 and 30. In the Scatter plot, the selected region will be shown as a rectangular bounding box, with the unselected points inside being transparent. On the histogram, data points selected on the histogram but not in other selections will be drawn in gray, data points not selected on either element will be transparent, and only those points that are selected in _both_ plots will be shown in the default blue color. The Violin plot does not itself allow selections, but it will update to show the distribution of the selected points, with the original distribution being lighter (more transparent) behind it for comparison. Here, selecting high weights and intermediate mpg gives points with a lower range of horsepower in the Violin plot.\n", "\n", "The way this all works is for each selection to be collected into a shared \"selection expression\" that is then applied by every linked plot after any change to a selection:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "mpg_ls.selection_expr" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "e.g. a box selection on the *weight*,*accel* scatter element might look like this:\n", "\n", "```\n", "(((dim('weight') >= (3125.237)) & (dim('weight') <= (3724.860))) & (dim('accel') >= (13.383))) & (dim('accel') <= (19.678))\n", "```\n", "\n", "Additional selections in other plots add to this list of filters if enabled, while additional selections within the same plot are combined with an operator that depends on the `selection_mode`.\n", "\n", "To better understand how to configure linked brushing, let's create a [Panel](https://panel.holoviz.org) that makes widgets for the parameters of the `linked_selection` operation and lets us explore their effect interactively. Play around with different `cross_filter_mode` and `selection_mode` settings and observe their effects (hitting reset when needed to get back to an unselected state):" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "import panel as pn\n", "\n", "mpg_lsp = link_selections.instance()\n", "\n", "params = pn.Param(mpg_lsp, parameters=[\n", " 'cross_filter_mode', 'selection_mode', 'show_regions',\n", " 'selected_color', 'unselected_alpha', 'unselected_color'])\n", "\n", "pn.Row(params, mpg_lsp(w_accel_scatter + mpg_hist + violin))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Note that in recent versions of Bokeh (>=2.1.0) and HoloViews (1.13.4) it is also possible to toggle the selection mode directly in the Bokeh toolbar by toggling the menu on the box-select and lasso-select tools:\n", "\n", "" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Index-based selections\n", "\n", "So far we have worked entirely using range-based selections, which result in selection expressions based only on the axis ranges selected, not the actual data points. Range-based selection requires that all selectable dimensions are present on the datasets behind every plot, so that the selection expression can be evaluated to filter every plot down to the correct set of data points. Range-based selections also only support the `box_select` tool, as they are filtering the data based on a rectangular region of the visible space in that plot. (Of course, you can still combine multiple such boxes to build up to selections of other shapes, with `selection_mode='union'`.)\n", "\n", "You can also choose to use index-based selections, which generate expressions based not on axis ranges but on values of one or more index columns (selecting individual, specific data points, as for the Bokeh JavaScript-based linked brushing). For index-based selections, plots can be linked as long as the datasets underlying each plot all have those index columns, so that expressions generated from a selection on one plot can be applied to all of the plots. Ordinarily the index columns should be unique in combination (e.g. Firstname,Lastname), each specifying one particular data point out of your data so that it can be correlated across all plots.\n", "\n", "To use index-based selections, specify the `index_cols` that are present across your elements. In the example below we will load the shapes and names of counties from the US state of Texas and their corresponding unemployment rates. We then generate a choropleth plot and a histogram plot both displaying the unemployment rate." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "from bokeh.sampledata.us_counties import data as counties\n", "from bokeh.sampledata.unemployment import data as unemployment\n", "\n", "counties = [dict(county, Unemployment=unemployment[cid])\n", " for cid, county in counties.items()\n", " if county[\"state\"] == \"tx\"]\n", "\n", "detailed_name = 'detailed_name' if counties[0].get('detailed_name') else 'detailed name' # detailed name was changed in Bokeh 3.0\n", "choropleth = hv.Polygons(counties, ['lons', 'lats'], [(detailed_name, 'County'), 'Unemployment'])\n", "hist = choropleth.hist('Unemployment', adjoin=False, normed=False)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "To link the two we will specify the `'detailed name'` column as the `index_cols`." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "linked_choropleth = link_selections(choropleth + hist, index_cols=['detailed name'])" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Now that the two plots are linked we can display them and select individual polygons by tapping or apply a box selection on the histogram:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "linked_choropleth.opts(\n", " hv.opts.Polygons(tools=['hover', 'tap', 'box_select'], xaxis=None, yaxis=None,\n", " show_grid=False, show_frame=False, width=500, height=500,\n", " color='Unemployment', colorbar=True, line_color='white'),\n", " hv.opts.Histogram(width=500, height=500)\n", ")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "This type of linked brushing will work even for datasets not including the latitude and longitude axes of the choropleth plot, because each selected county resolves not to a geographic region but to a county name, which can then be used to index into any other dataset that includes the county name for each data point." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Styling selections\n", "\n", "By default, unselected objects will be indicated using a lower alpha value specified using the `unselected_alpha` keyword argument, which keeps unselected points the same color but makes them fade into the background. That way it should be safe to call `link_selections` on a plot without altering its visual appearance by default; you'll only see a visible difference once you select something. An alternative is to specify `selected_color` and an `unselected_color`, which can provide a more vivid contrast between the two states. To make sure both colors are visible ensure you also need to override the `unselected_alpha`:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "link_selections(w_accel_scatter + mpg_hist, selected_color='#ff0000', unselected_alpha=1, unselected_color='#90FF90')" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Plotly support\n", "\n", "Linked brushing also works with the Plotly backend, which, unlike Bokeh, has support for rendering 3D plots:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "hv.Store.set_current_backend('plotly')\n", "\n", "ds = hv.Dataset(autompg)\n", "\n", "sel = link_selections.instance(\n", " selected_color='#bf0000', unselected_color='#ff9f9f', unselected_alpha=1\n", ")\n", "\n", "scatter1 = hv.Scatter(ds, 'weight', 'accel')\n", "scatter2 = hv.Scatter(ds, 'mpg', 'displ')\n", "scatter3d = hv.Scatter3D(ds, ['mpg', 'hp', 'weight'])\n", "table = hv.Table(ds, ['name', 'origin', 'yr'], 'mpg')\n", "\n", "sel(scatter1 + scatter2 + scatter3d + table, selection_expr=((dim('origin')==1) & (dim('mpg') >16))).cols(2)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "hv.Store.set_current_backend('bokeh')" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Note that Plotly does not yet support _selecting_ in 3D, but you should be able to provide 2D views for selecting alongside the 3D plots." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Operations\n", "\n", "One of the major advantages linked selections in HoloViews provide over using plotting libraries directly is the fact that HoloViews keeps track of the full pipeline of operations that have been applied to a dataset, allowing selections to be applied to the original dataset and then replaying the entire processing pipeline.\n", "\n", "In the example below, we'll use an example from [Datashader](https://datashader.org/getting_started/Pipeline.html) that has a sum of five normal distributions of different widths, each with its own range of values and its own category:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "import datashader as ds\n", "import holoviews.operation.datashader as hd\n", "\n", "num = 100000\n", "np.random.seed(1)\n", "\n", "dists = {\n", " cat: pd.DataFrame({\n", " 'x': np.random.normal(x, s, num), \n", " 'y': np.random.normal(y, s, num), \n", " 'val': np.random.normal(val, 1.5, num), \n", " 'cat': cat\n", " }) for x, y, s, val, cat in \n", " [( 2, 2, 0.03, 10, \"d1\"), \n", " ( 2, -2, 0.10, 20, \"d2\"), \n", " ( -2, -2, 0.50, 30, \"d3\"), \n", " ( -2, 2, 1.00, 40, \"d4\"), \n", " ( 0, 0, 3.00, 50, \"d5\")]\n", "}\n", "\n", "points = hv.Points(pd.concat(dists), ['x', 'y'], ['val', 'cat'])\n", "datashaded = hd.datashade(points, aggregator=ds.count_cat('cat'))\n", "spreaded = hd.dynspread(datashaded, threshold=0.50, how='over')\n", "\n", "# Declare dim expression to color by cluster\n", "dim_expr = ((0.1+hv.dim('val')/10).round()).categorize(hv.Cycle('Set1').values)\n", "histogram = points.hist(num_bins=60, adjoin=False, normed=False).opts(color=dim_expr)\n", "\n", "link_selections(spreaded + histogram)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Here you can select a group of points on the datashaded plot (left) and see that the histogram updates to show that subset of points, and similarly you can select one or more groups in the histogram and see that the corresponding group of points is highlighted on the left. (Here we've color-coded the histogram to make it easy to see that relationship, i.e. that the small dot of red points has a `val` around 10, and the large cloud of orange points has a `val` around 50; usually such relationships won't be so easy to see!) Each time you make such a selection on either plot, the entire 2D spatial aggregation pipeline from Datashader is re-run on the first plot and the entire 1D aggregation by value is run on the second plot, allowing you to see how the subset of `x`,`y` and/or `val` values relates to the dataset as a whole. At no point is the actual data (100,000 points here) sent to the browser, but the browser still allows selecting against the original dataset so that all aggregate views of the data accurately reflect the selection." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Supported elements\n", "\n", "Not all elements can be used with the `link_selections` function, and those that do can support either being selected on (for range and/or index selections), displaying selections, or neither. Below we show most elements supporting range-based selections:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "colors = hv.Cycle('Category10').values\n", "\n", "area = autompg_ds.aggregate('yr', function=np.mean).to(hv.Area, 'yr', 'weight')\n", "bivariate = hv.Bivariate(autompg_ds, ['mpg', 'accel'], []).opts(show_legend=False)\n", "box_whisker = hv.BoxWhisker(autompg_ds, 'cyl', 'accel').sort()\n", "curve = autompg_ds.aggregate('yr', function=np.mean).to(hv.Curve, 'yr', 'mpg')\n", "spread = autompg_ds.aggregate('yr', function=np.mean, spreadfn=np.std).to(hv.Spread, 'yr', ['mpg', 'mpg_std'])\n", "distribution = hv.Distribution(autompg_ds, 'weight')\n", "img = hd.rasterize(hv.Points(autompg_ds, ['hp', 'displ']), dynamic=False, width=20, height=20)\n", "heatmap = hv.HeatMap(autompg_ds, ['yr', 'origin'], 'accel').aggregate(function=np.mean)\n", "hextiles = hv.HexTiles(autompg_ds, ['weight', 'displ'], []).opts(gridsize=20)\n", "hist = autompg_ds.hist('displ', adjoin=False, normed=False)\n", "scatter = hv.Scatter(autompg_ds, 'mpg', 'hp')\n", "violin = hv.Violin(autompg_ds, 'origin', 'mpg').sort()\n", "\n", "link_selections(\n", " area + bivariate + box_whisker + curve +\n", " distribution + heatmap + hextiles + hist +\n", " img + scatter + spread + violin\n", ").opts(\n", " opts.Area(color=colors[0]),\n", " opts.Bivariate(cmap='Blues'),\n", " opts.BoxWhisker(box_color=colors[1]),\n", " opts.Curve(color=colors[2]),\n", " opts.Distribution(color=colors[3]),\n", " opts.HexTiles(cmap='Purples'),\n", " opts.HeatMap(cmap='Greens'),\n", " opts.Histogram(color=colors[4]),\n", " opts.Image(cmap='Reds'),\n", " opts.Scatter(color=colors[5]),\n", " opts.Spread(color=colors[6]),\n", " opts.Violin(violin_fill_color=colors[7]),\n", ")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "The table below can be used for reference to see which elements support displaying selections, making index-based selections, and making range-based selections. Elements that do not support selection are not listed in the table at all, including **Div**, **ItemTable**, and **Tiles** along with Annotations (**Arrow**, **Bounds**, **Box**, **Ellipse**, **HLine**, **HSpan**, **Slope**, **Spline**, **Text**, **VLine**, **VSpan**). Notes are provided for some elements that _could_ be supported, but are not yet supported due to various complications as listed." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "\n", "\n", "
\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", " \n", "\n", " \n", "\n", " \n", "\n", " \n", "\n", " \n", "\n", " \n", "\n", " \n", "\n", " \n", "\n", " \n", "\n", " \n", "\n", " \n", "\n", " \n", "\n", " \n", "\n", " \n", "\n", " \n", "\n", " \n", "\n", " \n", "\n", " \n", "\n", " \n", "\n", " \n", "\n", " \n", "\n", " \n", "\n", " \n", "\n", " \n", "\n", " \n", "\n", " \n", "\n", " \n", "\n", " \n", "\n", " \n", "\n", " \n", "\n", " \n", "\n", " \n", "\n", " \n", "\n", " \n", "\n", " \n", "\n", " \n", "\n", " \n", "\n", "
ElementDisplay selectionsIndex-based selectionsRange-based selectionsLasso-based selectionsNotes
AreaYesYesYesNo
BarsNo No No No Complicated to support stacked and multi-level bars
BivariateYesYesYesYes
BoxWhiskerYesYesYesNo
ChordNo No No No Complicated to support composite elements
ContoursYesYesNo Yes
CurveYesYesYesNo
DistributionYesYesYesNo
ErrorBarsYesNo No No
GraphNo No No Yes Complicated to support composite elements
HeatMapYesYesYesYes
HexTilesYesYesYesYes
HistogramYesYesYesNo
HSVYesYesYesYes
ImageYesYesYesYes
LabelsYesNo No Yes
Path3DNo No No No Complicated to support masking partial paths; no 3D selections
PathNo No No No Complicated to support masking partial paths
PointsYesYesYesYes
PolygonsYesYesNo Yes
QuadMeshYesYesYesYes
RadialHeatMapYesNo No No
RasterNo No No No Special-case code that is difficult to replace
RectanglesYesYesYesYes
RGBYesYesYesYes
SankeyNo No No No Complicated to support composite elements
ScatterYesYesYesYes
Scatter3DYesNo No No 3D selections not yet available
SegmentsYesYesYesYes
SpikesYesYesYesNo
SpreadYesYesYesNo
SurfaceYesNo No No 3D selections not yet available
TableYesYesNo No
TriMeshNo No No No Complicated to support composite elements
TriSurfaceYesNo No No 3D selections not yet available
VectorFieldYesYesYesYes
ViolinYesYesYesNo
" ] } ], "metadata": { "language_info": { "name": "python", "pygments_lexer": "ipython3" } }, "nbformat": 4, "nbformat_minor": 4 }