Plotly.py: Too much space between subplots using facet_row in express

Created on 27 Nov 2019  路  19Comments  路  Source: plotly/plotly.py

plotly==4.1.1
plotly-express==0.4.0
jupyter-lab==1.1.4

When using px.scatter with color and facet_row, the figure adds a ton of space between subplots. Need to give it excessive height to make plots square.

Also, .update_layout( showlegend=False) is not removing the legend. The figure below has 20 subplots.

px.scatter(comps_year, x = 'count', y = 'sum', color = 'YEAR', facet_row= 'COMPANY_CLEAN', width = 500, height = 9000).update_layout(showlegend=False)

Capture

When I remove the width and height parameters, it renders like this:

Capture2

Most helpful comment

As of plotly 4.9, we now have new facet_row_spacing and facet_col_spacing arguments: https://plotly.com/python/facet-plots/#controlling-facet-spacing

All 19 comments

Hey Roberto!

Sounds like you want 20ish roughly-square facets? With Plotly 4.3.0 you can pass facet_col_wrap=5 to get 5 rows of 4!

Also, unintuitive as it might be, the thing on the right is not a "legend" in Plotly-land, rather it's a "color bar" and it can be hidden with .update_layout(colorbar_showscale=False).

Finally: you can keep plotly_express around if you like but it's not really doing anything any more... it just re-exports plotly.express, so you may as well just import that from now on.

Hope this helps!

Well, it seems like I missed this pretty major update. facet_col_wrap is exactly what I needed. However, colorbar_showscale is not being accepted as a valid property for update_layout.

Thanks, Nick!

Sorry, typo'ed: .update_layout(coloraxis_showscale=False)

Oh and I never answered your original question: the reason you get all this padding is because the padding is specified as a percentage of the overall figure size, rather than being fixed. So stretching the figure will stretch the plots and the padding in equal measure ;)

@nicolaskruchten Is there a way to specify the padding? I'd like to keep the subplots taller but close together.

Unfortunately there is not a way to control the padding, no :(

You could iterate through the yaxis and xaxis keys in fig.layout and manually set the domain to resize the plots. Kinda manual/kinda gross but will work.

Joining the party late but this can probably helpful.

The modification of the axes' domain can be simplified using the make_subpots function.
make_subpots allows for vertical and horizontal spacing therefore it can provide us with the required domains.

We first create a subplot to use as a template.

from plotly.subplots import make_subplots
sp = make_subplots(rows=nrows, cols=ncols, horizontal_spacing=0.1, vertical_spacing=0.1)

nrows and ncols must be calculated based on the plot we want to imitate.

Then we produce our plot
fig = px.line(df, x, y, facet_col='FR', facet_col_wrap=2)

Finally we iterate and fix the domains based on the sp . The issue is that facet plots and subplots arrange the plots differently. The following function resolves this issue.

def modify_facet_spacing(fig, nrows, ncols, hs, vs):

    import re
    pattern = r'[xy]axis(\d+)?'

    sp = make_subplots(rows=nrows, cols=ncols, horizontal_spacing=hs, vertical_spacing=vs)

    # the subplots arange plots differently than faceted plots 
    # subplots: top left, facets: bottom left
    #pdb.set_trace()
    spt_rng = np.arange(1, (nrows*ncols)+1)
    fct_rng = np.flip(spt_rng.reshape(nrows, ncols), axis=0).flatten()

    repl = dict(zip(fct_rng, spt_rng))

    for el in fig.layout:
        if re.match(pattern, el):

            axis_fct = el
            axis_fct_n = int(re.match(pattern, axis_fct).groups(0)[0])

            if 'yaxis' in axis_fct: 
                if axis_fct_n == 0:
                    #pdb.set_trace()
                    axis_fct_n = 1
                    axis_fct += str(axis_fct_n)

                axis_spt = axis_fct.replace(str(axis_fct_n), str(repl[axis_fct_n]))
            else:
                axis_spt = axis_fct

            if axis_fct.endswith('axis1'):
                axis_fct = axis_fct[:-1]

            if axis_spt.endswith('axis1'):
                axis_spt = axis_spt[:-1]

            fig.layout[axis_fct].update(domain=sp.layout[axis_spt].domain)

The function has not been thoroughly tested, therefore use with caution. Pay attention to provide the helper function with the appropriate number of expected rows and cols.

Hope that helps!

Before modification:
image

After modification:
image

@harisbal - thanks for that - was very useful for me. One addition I made was to adjust the facet column title y coordinates as follows, because they were staying at the original (padded downward) locations, progressively shifting toward the bottom of each chart, which gets really bad for large facet arrays:

def modify_facet_spacing(fig, ncols, hs, vs):

    import re, math, numpy as np

    pattern = r'[xy]axis(\d+)?'

    fct_titles = fig.layout['annotations']
    nrows = int(math.ceil(len(fct_titles) / ncols))  #this needs revision for cases where plotly express has removed redundant rows (if for eg the bottom row or two are empty of charts)

    sp = make_subplots(rows=nrows, cols=ncols, horizontal_spacing=hs, vertical_spacing=vs)

    # the subplots arange plots differently than faceted plots 
    # subplots: top left, facets: bottom left
    #pdb.set_trace()
    spt_rng = np.arange(1, (nrows*ncols)+1)
    fct_rng = np.flip(spt_rng.reshape(nrows, ncols), axis=0).flatten()
    repl = dict(zip(fct_rng, spt_rng))

   empty_cells = (nrows * ncols) - len(fct_titles)

    for el in fig.layout:

        if re.match(pattern, el):
            axis_fct = el
            axis_fct_n = int(re.match(pattern, axis_fct).groups(0)[0])

            if 'yaxis' in axis_fct:
                if axis_fct_n == 0:
                    #pdb.set_trace()
                    axis_fct_n = 1
                    axis_fct += str(axis_fct_n)
                axis_spt = axis_fct.replace(str(axis_fct_n), str(repl[axis_fct_n]))
            else:
                axis_spt = axis_fct

            spt_index = int(re.match(pattern, axis_spt).groups(0)[0]) - 1
            fct_index = axis_fct_n - 1

            if axis_fct.endswith('axis1'):
                axis_fct = axis_fct[:-1]
            if axis_spt.endswith('axis1'):
                axis_spt = axis_spt[:-1]
            fig.layout[axis_fct].update(domain=sp.layout[axis_spt].domain)

            if 'yaxis' in axis_fct:
                # facets are nrows*ncols in size behind the scenes, but 
                # fct_titles is only ncharts long (eg if 1 plot on last row) -
                # the rest of the plots on that row are empty
                if fct_index - empty_cells >= 0:
                    fct_titles[fct_index - empty_cells]['y'] = sp.layout[axis_spt].domain[1]

    fig.layout['annotations'] = fct_titles

Glad I could help. Very nice improvement.
Just out of curiosity the 5 in this line spt_index = int(axis_spt[-(len(axis_spt) - 5):]) - 1 is specific to your case right?

I had also tried to address the issue, but your version is probably more robust

def modify_facet_spacing(fig, nrows, ncols, hs, vs, xannot=1, yannot=1.05, ammend_annots=True):

    import re

    sp = make_subplots(rows=nrows, cols=ncols, horizontal_spacing=hs, vertical_spacing=vs)

    # the subplots arange plots differently than faceted plots 
    # subplots: top left, facets: bottom left
    #pdb.set_trace()
    spt_rng = np.arange(1, (nrows*ncols)+1)
    fct_rng = np.flip(spt_rng.reshape(nrows, ncols), axis=0).flatten()

    repl = dict(zip(fct_rng, spt_rng))
    pattern = r'[xy]axis(\d+)?'

    for el in fig.layout:
        if re.match(pattern, el):

            axis_fct = el
            axis_fct_n = int(re.match(pattern, axis_fct).groups(0)[0])

            if 'yaxis' in axis_fct: 
                if axis_fct_n == 0:
                    axis_fct_n = 1
                    axis_fct += str(axis_fct_n)

                axis_spt = axis_fct.replace(str(axis_fct_n), str(repl[axis_fct_n]))
            else:
                axis_spt = axis_fct

            fig.layout[axis_fct].update(domain=sp.layout[axis_spt].domain)

            if ammend_annots:
                axis_fct_n = int(re.match(pattern, axis_fct).groups(0)[0])
                if axis_fct_n == 0:
                    axis_fct_n = 1
                # Assumption that annotations follow the same order with axes
                if axis_fct[0] == 'x':
                    dom = fig.layout[axis_fct]['domain']
                    mid = dom[0] + ((dom[1] - dom[0])/2) * xannot
                    fig.layout.annotations[axis_fct_n-1].update(xref='paper', x=mid, xanchor='center')
                elif axis_fct[0] == 'y':
                    top = fig.layout[axis_fct]['domain'][1] * yannot
                    fig.layout.annotations[axis_fct_n-1].update(yref='paper', y=top, yanchor='middle')
                else:
                    print('Unrecognised axis type')

Glad I could help. Very nice improvement.
Just out of curiosity the 5 in this line spt_index = int(axis_spt[-(len(axis_spt) - 5):]) - 1 is specific to your case right?

Yep you're right, that should be smarter than as I have it. Secondary axes etc might break it. I'll update that here at some point. Cheers
...Edit - updated in my code / comment above. Good pickup, thanks.

We should probably add facet_row_spacing as a kwarg :) and same for col

Hi Guys, It helped me a lot...

I have a good question, how can I change the order of the charts of the facet_row?

Also, I'm trying to set a custom color to the bars, but it's applying only to the first frame of the animation. How to change the color to all frames?

@kaburelabs this is a bit off-topic, but you can use category_orders to control order of anything categorical, and color_discrete_map to map specific colors to categories: https://plotly.com/python/styling-plotly-express/

Thank you, Nicolas!
I have implemented it looping through the frames and setting the colors for each chart

Oh and I never answered your original question: the reason you get all this padding is because the padding is specified as a percentage of the overall figure size, rather than being fixed. So stretching the figure will stretch the plots and the padding in equal measure ;)

@nicolaskruchten - I wonder if this concept is a reason for #2026 (this affects me too at times) and #2556. I haven't looked into the underlying code, but isn't that guaranteed to mean negative plot area y dimensions (within each facet) beyond a certain number of facet rows? Would an "easy fix" (excuse me) for these issues be to change this padding to be a % of each facet's domain rather than % of the overall figure size?

Chris.

@cnicol-gwlogic yes, that would be reasonably easy but might be a breaking change, and in any case would lead to ugly plots in many cases unless folks "tune" height and width, so the easiest thing to do might be to allow folks to override the built-in default themselves.

As of plotly 4.9, we now have new facet_row_spacing and facet_col_spacing arguments: https://plotly.com/python/facet-plots/#controlling-facet-spacing

Was this page helpful?
0 / 5 - 0 ratings

Related issues

jisaacso picture jisaacso  路  4Comments

fcollonval picture fcollonval  路  3Comments

dhirschfeld picture dhirschfeld  路  4Comments

gv-collibris picture gv-collibris  路  4Comments

suciokhan picture suciokhan  路  3Comments