df.style
¶possible API:
We'd like to conditionally format a dataframe. We'd like to be able to format cells based on
(these may be incompatible... not sure).
The API could be somthing like
df.style(rows={'A': func1, 'B': func2}, columns=None, table=func3)
or maybe
df.style(rows={'A': [func1, func2], 'B': func2}, table=None)
or maybe
df.style(columns=func1)
The last example is sugar for df.style(columns=dict(zip(df.columns, cycle([func1])))
i.e.
apply func1
to each column.
What's the API for each func
?
def f(values)
pass
where values
is either a Series for rows
or columns
, or a DataFrame for table
.
Each style function must return a like-indexed Series/DataFrame of (css-selecter, value) pairs. These styles are applied when we generate the HTML.
This should cover most cases. The only other thing is maybe the index / columns. Optionally allow for index=
and header=
. Maybe.
I think this strikes a reasonable balance between complexity / flexibility. Users can "share" styles.
I haven't implemented the actuall HTML generation yet.
@yp did a bunch of work on this in https://github.com/pydata/pandas/issues/3190. I've included it below. His method used Jinja2 templates. I somewhat like my API more than his, but I'm not convinced it's actually better.
from matplotlib.colors import rgb2hex
import pandas.core.common as com
def monkeypatch_method(cls):
def decorator(func):
setattr(cls, func.__name__, func)
return func
return decorator
@monkeypatch_method(pd.DataFrame)
def style(self, rows=None, columns=None, frame=None):
styles = self._build_styles(
rows=rows, columns=columns, frame=frame
)
self.to_html(styles)
@monkeypatch_method(pd.DataFrame)
def _format_sytle_args(self, rows=None, columns=None, frame=None):
if rows is not None:
# a signle function, apply to each row
if callable(rows):
rows = {row: [rows] for row in self.index}
else:
rows = {row: [x] if not com.is_list_like(x) else x
for row, x in rows.items()}
if columns is not None:
if callable(columns):
columns = {col: [columns] for col in self.columns}
else:
columns = {col: [x] if not com.is_list_like(x) else x
for col, x in columns.items()}
if frame is not None:
frame = [frame] if not com.is_list_like(frame) else frame
return rows, columns, frame
@monkeypatch_method(pd.DataFrame)
def _build_styles(self, rows=None, columns=None, frame=None):
final = [] # (row, col, prop, value)
rows, columns, frame = self._format_sytle_args(rows, columns, frame)
if rows is not None:
for row in rows:
for func in rows[row]:
props, values = zip(*func(self.loc[row]))
final.append(pd.DataFrame({'row': row, 'col': self.columns,
'prop': props, 'value': values}))
if columns is not None:
for col in columns:
for func in columns[col]:
props, values = zip(*func(self[col]))
final.append(pd.DataFrame({'row': self.index, 'col': col,
'prop': props, 'value': values}))
if frame is not None:
for func in frame:
r = func(self)
for col in r:
try:
prop, values = zip(*r[col])
except ValueError:
prop, values = '', ''
final.append(pd.DataFrame({'row': r.index, 'col': col,
'prop': props, 'value': values}))
r = pd.concat(final)[['row', 'col', 'prop', 'value']]
r = (r['prop'] + ':' + r['value']).groupby([r['row'], r['col']]).agg(
lambda x: '; '.join(x))
return r.unstack()
def color_bg_range(s, cmap='PuBu'):
"""Color background in a range."""
colors = [rgb2hex(x) for x in plt.cm.get_cmap(cmap)(s)]
return pd.Series([('backgroud-color', color) for color in colors])
def color_font_even(s):
colors = ['black' if x % 2 else 'red' for x in s]
return pd.Series([('font-color', color) for color in colors])
def color_gradient(s, cmap='RdBu'):
colors = [rgb2hex(x) for x in plt.cm.get_cmap(cmap)(range(len(s)))]
return pd.Series([('background-color', color) for color in colors])
def striped(s):
colors = ["B5DEFF" if i % 2 else "" for i in range(len(s))]
return pd.Series([('background-color', color) for color in colors])
np.random.seed(24)
s = pd.DataFrame({'A': np.random.permutation(range(6))})
df = pd.DataFrame({'A': np.linspace(1, 5, 5), 'B': np.random.randn(5)})
df
A | B | |
---|---|---|
0 | 1 | -0.609561 |
1 | 2 | -1.228095 |
2 | 3 | -0.808719 |
3 | 4 | -0.408644 |
4 | 5 | -1.548511 |
df._build_styles(columns={"A": [color_bg_range, color_font_even], 'B': color_bg_range})
col | A | B |
---|---|---|
row | ||
0 | backgroud-color:#023858; font-color:black | backgroud-color:#fff7fb |
1 | backgroud-color:#023858; font-color:red | backgroud-color:#fff7fb |
2 | backgroud-color:#023858; font-color:black | backgroud-color:#fff7fb |
3 | backgroud-color:#023858; font-color:red | backgroud-color:#fff7fb |
4 | backgroud-color:#023858; font-color:black | backgroud-color:#fff7fb |
df._build_styles(columns={'A': color_gradient})
col | A |
---|---|
row | |
0 | background-color:#67001f |
1 | background-color:#6a011f |
2 | background-color:#6d0220 |
3 | background-color:#700320 |
4 | background-color:#730421 |
def is_positive_bg(s):
colors = {True: ('backgroud-color', 'teal'),
False: ('background-color', 'blue')}
if isinstance(s, pd.Series):
return (s > 0).map(colors)
else:
return (s > 0).applymap(lambda x: colors[x])
def is_even_font(s):
colors = {True: ('font-color', 'red'),
False: ('font-color', 'yellow')}
if isinstance(s, pd.Series):
return (s % 2).map(colors)
else:
return(s % 0).applymap(lambda x: colors[x])
pairs = is_positive_bg(df)
pairs
A | B | |
---|---|---|
0 | (backgroud-color, teal) | (background-color, blue) |
1 | (backgroud-color, teal) | (background-color, blue) |
2 | (backgroud-color, teal) | (background-color, blue) |
3 | (backgroud-color, teal) | (background-color, blue) |
4 | (backgroud-color, teal) | (background-color, blue) |
df._build_styles(columns={'A': is_even_font}, frame=[is_positive_bg])
col | A |
---|---|
row | |
0 | font-color:red |
1 | font-color:yellow |
2 | font-color:red |
3 | font-color:yellow |
4 | font-color:red |
from jinja2 import Template
from pandas.util.testing import makeCustomDataframe as mkdf
# The baseline jinja2 template for HTML output.
t=Template("""
<style type="text/css" >
#T_{{uuid}} tr {
border: none;
}
#T_{{uuid}} {
border: none;
}
#T_{{uuid}} th.blank {
border: none;
}
{% for s in style %}
#T_{{uuid}} {{s.selector}} {
{% for p,val in s.props %}
{{p}}: {{val}};
{% endfor %}
}
{% endfor %}
</style>
<table id="T_{{uuid}}">
{% if caption %}
<caption>
{{caption}}
</caption>
{% endif %}
<thead>
{% for r in head %}
<tr>
{% for c in r %}
<{{c.type}} class="{{c.class}}">{{c.value}}</th>
{% endfor %}
</tr>
{% endfor %}
</thead>
<tbody>
{% for r in body %}
<tr>
{% for c in r %}
<{{c.type}} class="{{c.class}}">{{c.value}}</th>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
""")
ROW_HEADING_CLASS="row_heading"
COL_HEADING_CLASS="col_heading"
DATA_CLASS="data"
BLANK_CLASS="blank"
BLANK_VALUE=""
def translate(df,cell_context=None):
import uuid
cell_context = cell_context or dict()
n_rlvls =df.index.nlevels
n_clvls =df.columns.nlevels
rlabels=df.index.tolist()
clabels=df.columns.tolist()
if n_rlvls == 1:
rlabels = [[x] for x in rlabels]
if n_clvls == 1:
clabels = [[x] for x in clabels]
clabels=list(zip(*clabels))
head=[]
for r in range(n_clvls):
row_es = [{"type":"th","value":BLANK_VALUE ,"class": " ".join([BLANK_CLASS])}]*n_rlvls
for c in range(len(clabels[0])):
cs = [COL_HEADING_CLASS,"level%s" % r,"col%s" %c]
cs.extend(cell_context.get("col_headings",{}).get(r,{}).get(c,[]))
row_es.append({"type":"th","value": clabels[r][c],"class": " ".join(cs)})
head.append(row_es)
body=[]
for r in range(len(df)):
cs = [ROW_HEADING_CLASS,"level%s" % c,"row%s" % r]
cs.extend(cell_context.get("row_headings",{}).get(r,{}).get(c,[]))
row_es = [{"type":"th","value": rlabels[r][c],"class": " ".join(cs)}
for c in range(len(rlabels[r]))]
for c in range(len(df.columns)):
cs = [DATA_CLASS,"row%s" % r,"col%s" %c]
cs.extend(cell_context.get("data",{}).get(r,{}).get(c,[]))
row_es.append({"type":"td","value": df.iloc[r][c],"class": " ".join(cs)})
body.append(row_es)
# uuid required to isolate table styling from others
# in same notebook in ipnb
u = str(uuid.uuid1()).replace("-","_")
return dict(head=head, body=body,uuid=u)
df=mkdf(10,5,r_idx_nlevels=3,c_idx_nlevels=2)
from IPython.display import HTML,display
ctx= translate(df)
ctx['caption']="Just a table, but rendered using a template with lots of classes to style against"
display(HTML(t.render(**ctx)))
C_l0_g0 | C_l0_g1 | C_l0_g2 | C_l0_g3 | C_l0_g4 | |||
---|---|---|---|---|---|---|---|
C_l1_g0 | C_l1_g1 | C_l1_g2 | C_l1_g3 | C_l1_g4 | |||
R_l0_g0 | R_l1_g0 | R_l2_g0 | R0C0 | R0C1 | R0C2 | R0C3 | R0C4 |
R_l0_g1 | R_l1_g1 | R_l2_g1 | R1C0 | R1C1 | R1C2 | R1C3 | R1C4 |
R_l0_g2 | R_l1_g2 | R_l2_g2 | R2C0 | R2C1 | R2C2 | R2C3 | R2C4 |
R_l0_g3 | R_l1_g3 | R_l2_g3 | R3C0 | R3C1 | R3C2 | R3C3 | R3C4 |
R_l0_g4 | R_l1_g4 | R_l2_g4 | R4C0 | R4C1 | R4C2 | R4C3 | R4C4 |
R_l0_g5 | R_l1_g5 | R_l2_g5 | R5C0 | R5C1 | R5C2 | R5C3 | R5C4 |
R_l0_g6 | R_l1_g6 | R_l2_g6 | R6C0 | R6C1 | R6C2 | R6C3 | R6C4 |
R_l0_g7 | R_l1_g7 | R_l2_g7 | R7C0 | R7C1 | R7C2 | R7C3 | R7C4 |
R_l0_g8 | R_l1_g8 | R_l2_g8 | R8C0 | R8C1 | R8C2 | R8C3 | R8C4 |
R_l0_g9 | R_l1_g9 | R_l2_g9 | R9C0 | R9C1 | R9C2 | R9C3 | R9C4 |
def zebra(color1, color2):
return [dict(selector="td.data:nth-child(2n)" ,
props=[("background-color",color1)]),
dict(selector="td.data:nth-child(2n+1)" ,
props=[("background-color",color2)])]
ctx= translate(df)
style=[]
style.extend(zebra("#aaa","#ddd"))
ctx['style']=style
ctx['caption']="A zebra table"
display(HTML(t.render(**ctx)))
C_l0_g0 | C_l0_g1 | C_l0_g2 | C_l0_g3 | C_l0_g4 | |||
---|---|---|---|---|---|---|---|
C_l1_g0 | C_l1_g1 | C_l1_g2 | C_l1_g3 | C_l1_g4 | |||
R_l0_g0 | R_l1_g0 | R_l2_g0 | R0C0 | R0C1 | R0C2 | R0C3 | R0C4 |
R_l0_g1 | R_l1_g1 | R_l2_g1 | R1C0 | R1C1 | R1C2 | R1C3 | R1C4 |
R_l0_g2 | R_l1_g2 | R_l2_g2 | R2C0 | R2C1 | R2C2 | R2C3 | R2C4 |
R_l0_g3 | R_l1_g3 | R_l2_g3 | R3C0 | R3C1 | R3C2 | R3C3 | R3C4 |
R_l0_g4 | R_l1_g4 | R_l2_g4 | R4C0 | R4C1 | R4C2 | R4C3 | R4C4 |
R_l0_g5 | R_l1_g5 | R_l2_g5 | R5C0 | R5C1 | R5C2 | R5C3 | R5C4 |
R_l0_g6 | R_l1_g6 | R_l2_g6 | R6C0 | R6C1 | R6C2 | R6C3 | R6C4 |
R_l0_g7 | R_l1_g7 | R_l2_g7 | R7C0 | R7C1 | R7C2 | R7C3 | R7C4 |
R_l0_g8 | R_l1_g8 | R_l2_g8 | R8C0 | R8C1 | R8C2 | R8C3 | R8C4 |
R_l0_g9 | R_l1_g9 | R_l2_g9 | R9C0 | R9C1 | R9C2 | R9C3 | R9C4 |
def tag_col(n,c="grey10", with_headings=False):
selector="td.col%d" % n
if not with_headings:
selector+=".data"
return [dict(selector=selector,
props=[("background-color",c)])]
def tag_row(n,c="grey10", with_headings=False):
selector="td.row%d" % n
if not with_headings:
selector+=".data"
return [dict(selector=selector,
props=[("background-color",c)])]
ctx= translate(df)
style=[]
style.extend(tag_col(2,"beige"))
style.extend(tag_row(3,"purple"))
ctx['style']=style
ctx['caption']="Highlight rows/cols by index"
display(HTML(t.render(**ctx)))
C_l0_g0 | C_l0_g1 | C_l0_g2 | C_l0_g3 | C_l0_g4 | |||
---|---|---|---|---|---|---|---|
C_l1_g0 | C_l1_g1 | C_l1_g2 | C_l1_g3 | C_l1_g4 | |||
R_l0_g0 | R_l1_g0 | R_l2_g0 | R0C0 | R0C1 | R0C2 | R0C3 | R0C4 |
R_l0_g1 | R_l1_g1 | R_l2_g1 | R1C0 | R1C1 | R1C2 | R1C3 | R1C4 |
R_l0_g2 | R_l1_g2 | R_l2_g2 | R2C0 | R2C1 | R2C2 | R2C3 | R2C4 |
R_l0_g3 | R_l1_g3 | R_l2_g3 | R3C0 | R3C1 | R3C2 | R3C3 | R3C4 |
R_l0_g4 | R_l1_g4 | R_l2_g4 | R4C0 | R4C1 | R4C2 | R4C3 | R4C4 |
R_l0_g5 | R_l1_g5 | R_l2_g5 | R5C0 | R5C1 | R5C2 | R5C3 | R5C4 |
R_l0_g6 | R_l1_g6 | R_l2_g6 | R6C0 | R6C1 | R6C2 | R6C3 | R6C4 |
R_l0_g7 | R_l1_g7 | R_l2_g7 | R7C0 | R7C1 | R7C2 | R7C3 | R7C4 |
R_l0_g8 | R_l1_g8 | R_l2_g8 | R8C0 | R8C1 | R8C2 | R8C3 | R8C4 |
R_l0_g9 | R_l1_g9 | R_l2_g9 | R9C0 | R9C1 | R9C2 | R9C3 | R9C4 |
def round_corners(radius):
props_bl=[
("-moz-border-radius-bottomleft", "%dpx" % radius ),
("-webkit-border-bottom-left-radius", "%dpx" % radius ),
("border-bottom-left-radius", "%dpx" % radius )
]
props_br=[
("-moz-border-radius-bottomright", "%dpx" % radius ),
("-webkit-border-bottom-right-radius", "%dpx" % radius ),
("border-bottom-right-radius", "%dpx" % radius )
]
props_tl=[
("-moz-border-radius-topleft", "%dpx" % radius ),
("-webkit-border-top-left-radius", "%dpx" % radius ),
("border-top-left-radius", "%dpx" % radius )
]
props_tr=[
("-moz-border-radius-topright", "%dpx" % radius ),
("-webkit-border-top-right-radius", "%dpx" % radius ),
("border-top-right-radius", "%dpx" % radius )
]
return [dict(selector="td",
props=[("border-width","1px")]),
dict(selector="",
props=[("border-collapse","separate")]),
dict(selector="tr:last-child th:first-child",
props=props_bl),
dict(selector="tr:last-child td:last-child",
props=props_br),
dict(selector="tr:first-child th.col0",
props=props_tl),
dict(selector="tr:first-child th.row0:first-child",
props=props_tl),
dict(selector="tr:first-child th:last-child",
props=props_tr),
]
ctx= translate(df)
style=[]
style.extend(round_corners(5))
ctx['caption']="Rounded corners. CSS skills beginning to fail."
ctx['style']=style
display(HTML(t.render(**ctx)))
C_l0_g0 | C_l0_g1 | C_l0_g2 | C_l0_g3 | C_l0_g4 | |||
---|---|---|---|---|---|---|---|
C_l1_g0 | C_l1_g1 | C_l1_g2 | C_l1_g3 | C_l1_g4 | |||
R_l0_g0 | R_l1_g0 | R_l2_g0 | R0C0 | R0C1 | R0C2 | R0C3 | R0C4 |
R_l0_g1 | R_l1_g1 | R_l2_g1 | R1C0 | R1C1 | R1C2 | R1C3 | R1C4 |
R_l0_g2 | R_l1_g2 | R_l2_g2 | R2C0 | R2C1 | R2C2 | R2C3 | R2C4 |
R_l0_g3 | R_l1_g3 | R_l2_g3 | R3C0 | R3C1 | R3C2 | R3C3 | R3C4 |
R_l0_g4 | R_l1_g4 | R_l2_g4 | R4C0 | R4C1 | R4C2 | R4C3 | R4C4 |
R_l0_g5 | R_l1_g5 | R_l2_g5 | R5C0 | R5C1 | R5C2 | R5C3 | R5C4 |
R_l0_g6 | R_l1_g6 | R_l2_g6 | R6C0 | R6C1 | R6C2 | R6C3 | R6C4 |
R_l0_g7 | R_l1_g7 | R_l2_g7 | R7C0 | R7C1 | R7C2 | R7C3 | R7C4 |
R_l0_g8 | R_l1_g8 | R_l2_g8 | R8C0 | R8C1 | R8C2 | R8C3 | R8C4 |
R_l0_g9 | R_l1_g9 | R_l2_g9 | R9C0 | R9C1 | R9C2 | R9C3 | R9C4 |
def color_class(cls, color):
return [dict(selector="td.%s" % cls ,
props=[("background-color",color)])]
def rank_col(n,ranking,u):
data = {i: {n: ["%s-%s" % (u,ranking[i])]} for i in range(len(ranking))}
return {"data": data}
import uuid
u = "U"+str(uuid.uuid1()).replace("-","_")
df=mkdf(9,5,data_gen_f=lambda r,c:np.random.random())
ranking=df.iloc[:,1].argsort().tolist()
cell_context=rank_col(1, ranking, u)
ctx= translate(df,cell_context)
style=[]
# http://colorbrewer2.org/
color_scale=["#fff7ec","#fee8c8","#fdd49e","#fdbb84","#fc8d59","#ef6548","#d7301f","#b30000","#7f0000"]
for intensity in range(9):
style.extend(color_class("%s-%s" % (u,intensity),color_scale[intensity]))
ctx['style']=style
ctx['caption']="And finally, a heatmap based on values"
display(HTML(t.render(**ctx)))
C_l0_g0 | C_l0_g1 | C_l0_g2 | C_l0_g3 | C_l0_g4 | |
---|---|---|---|---|---|
R_l0_g0 | 0.573429038918 | 0.82003711633 | 0.560891050646 | 0.35076246072 | 0.543499756094 |
R_l0_g1 | 0.879589091712 | 0.114096564872 | 0.0314388054436 | 0.952810060373 | 0.28874347436 |
R_l0_g2 | 0.441949170885 | 0.259021532261 | 0.596891443727 | 0.655286045977 | 0.275695460646 |
R_l0_g3 | 0.857972457939 | 0.888724146384 | 0.285060591065 | 0.659560419109 | 0.972120259367 |
R_l0_g4 | 0.796874112556 | 0.179464401177 | 0.784672977888 | 0.970127888576 | 0.362811768986 |
R_l0_g5 | 0.0878860647601 | 0.343833423729 | 0.571109645691 | 0.16551268246 | 0.631615938853 |
R_l0_g6 | 0.0655655352988 | 0.912874191205 | 0.0886539792268 | 0.199387678302 | 0.471952697951 |
R_l0_g7 | 0.440997180198 | 0.765551426689 | 0.0127435336669 | 0.680662964171 | 0.275602639924 |
R_l0_g8 | 0.603979821258 | 0.545972846187 | 0.209789810551 | 0.13612275368 | 0.769162613954 |