pythonpandas

Saving styled pandas dataframe as image throws ValueError


I have python code like so:

import dataframe_image as dfi

def color_negative_red(val):
    color = f'rgba(255, 50, 50, {min(1, val / 350)})'
    return f'background-color: {color}'
styled_df = df.style.applymap(color_negative_red, subset=pd.IndexSlice[:, pd.IndexSlice['Active (bps)', :]])
dfi.export(styled_df, 'test.png', table_conversion='matplotlib')

but it throws this error:

ValueError: Invalid RGBA argument: 'rgba(255, 50, 50, 0.002857)'

df has MultiIndex columns and I want to color only "Active (bps)" columns:

import pandas as pd

data = {
    ('Act', 'bps'): [-14, 341, -14],
    ('Dur', 'bps'): [49, 379, 50],
    ('Active (bps)', '3M'): [1, -7, 3],
    ('Active (bps)', '1Y'): [3, 3, 4],
    ('Active (bps)', '2Y'): [14, 10, -36],
    ('Active (bps)', '3Y'): [118, 105, -59],
    ('Active (bps)', '5Y'): [-295, 205, 68],
    ('Active (bps)', '7Y'): [101, 25, 5]
}

df = pd.DataFrame(data)
df.index = ['NO2', 'BSB', 'GOB']
df.columns = pd.MultiIndex.from_tuples(df.columns)
print(df)


    Act    Dur    Active (bps)
    (bps)  (bps)  3M   1Y   2Y   3Y     5Y      7Y  
NO2 -14    49     1    3    14   118    -295    101
BSB 341    379    -7   3    10   105    205     25
GOB -14    50     3    4    -36  -59    68      5

Solution

  • Option 1: use default table_conversion='chrome'

    As specified in the doc string for dfi.export, under table_conversion:

    Use chrome unless you cannot get it to work. matplotlib provides a decent alternative.

    If you run into issues using the default, the fix might be this. The latest version of dfi, released yesterday (!), actually includes it (see here), so upgrading is surely worth a try.

    The point being that your code, with minor corrections, should work fine with 'chrome':

    import dataframe_image as dfi
    
    def color_negative_red(val):
        # minimal adjustment here: you need `max` to clip the lower bound at 0!
        color = f'rgba(255, 50, 50, {max(0, min(1, val / 350))})'
        return f'background-color: {color}'
    
    # using `map` instead of `applymap`, which is deprecated
    styled_df = df.style.map(color_negative_red, 
                             subset=pd.IndexSlice[:, pd.IndexSlice['Active (bps)', :]])
    
    dfi.export(styled_df, 'test.png')
    

    Result:

    table as png using chrome


    Option 2: use default table_conversion='matplotlib'

    One can get this option to work, but the result might still not be as desired, since matplotlib has a different way of handling the column labels (and then the column width). See the result below.

    At any rate, the main problem with our "decent alternative" is that in the conversion to mpl, you will enter this block of code, which expects the use of hex colors instead of an rgba string. This explains the somewhat cryptic error:

    ValueError: Invalid RGBA argument: 'rgba(255, 50, 50, 0.002857)'
    

    What is meant, is that the expected value (for variable c, to be used for a regex match) is something like '#fffefe'. To accommodate this, we need to convert your rgba colors into hex. This is a little bit tricky: to get from rgba to hex, we need to blend the rgb value with the rgb value of some background. Now, the background for each of your rows will be slightly different:

    (assuming use of default css setttings for Jupyter Notebook.)

    Let's define a helper function then, to get from rgba to hex:

    def rgba_to_hex_alpha(rgba, background=(255, 255, 255)):
        # unpack rgb + alpha
        *rgb, a = rgba
        
        # blended rgb
        blended_rgb = [int((1 - a) * i2 + a * i1) for i1, i2 in zip(rgb, background)]
        
        return '#{:02x}{:02x}{:02x}'.format(*blended_rgb)
    

    We will be calling this function inside a new version of color_negative_red, which we use inside df.style.apply. Note the use of helper=range(len(df)) below, which we pass alongside each column (axis=0) of the subset, to help decide whether we are looking at a value for an odd row or an even one.

    def color_negative_red(row, helper):
        vals = []
    
        # loop over `helper` and `row` simultaneously
        for idx, val in zip(helper, row):
            
            # `True` for row 0, 2, etc. (odd row!)
            if idx % 2 == 0:
                background = (245, 245, 245)
            else:
                background = (255, 255, 255)
                
            alpha = max(0, min(1, val / 350))
            color = rgba_to_hex_alpha(rgba=(255,50,50, alpha), background=background)
            bg = f'background-color: {color}'
            vals.append(bg)
        return vals
    
    styled_df = df.style.apply(color_negative_red, 
                               helper=range(len(df)), 
                               axis=0, 
                               subset=pd.IndexSlice[:, pd.IndexSlice['Active (bps)', :]]
                              )
    
    dfi.export(styled_df, 'test_matplotlib.png', table_conversion='matplotlib')
    

    Result:

    table as png using matplotlib

    (Incidentally, using dfi.export with default 'chrome' on this method will lead to the exact same result as shown at the end of option 1.)