# coding: utf-8
"""
Example plot functions for one-dimensional plots.
"""
from __future__ import annotations
__all__ = []
from collections import OrderedDict
import law
from columnflow.types import Iterable
from columnflow.util import maybe_import
from columnflow.plotting.plot_all import plot_all
from columnflow.plotting.plot_util import (
prepare_stack_plot_config,
prepare_style_config,
remove_residual_axis,
apply_variable_settings,
apply_process_settings,
apply_process_scaling,
apply_density,
hists_merge_cutflow_steps,
get_position,
get_profile_variations,
blind_sensitive_bins,
join_labels,
)
from columnflow.hist_util import add_missing_shifts
hist = maybe_import("hist")
np = maybe_import("numpy")
mpl = maybe_import("matplotlib")
plt = maybe_import("matplotlib.pyplot")
mplhep = maybe_import("mplhep")
od = maybe_import("order")
[docs]
def plot_variable_stack(
hists: OrderedDict,
config_inst: od.Config,
category_inst: od.Category,
variable_insts: list[od.Variable],
shift_insts: list[od.Shift] | None,
style_config: dict | None = None,
density: bool | None = False,
shape_norm: bool | None = False,
yscale: str | None = "",
process_settings: dict | None = None,
variable_settings: dict | None = None,
**kwargs,
) -> plt.Figure:
variable_inst = variable_insts[0]
# process-based settings (styles and attributes)
hists, process_style_config = apply_process_settings(hists, process_settings)
# variable-based settings (rebinning, slicing, flow handling)
hists, variable_style_config = apply_variable_settings(hists, variable_insts, variable_settings)
# process scaling
hists = apply_process_scaling(hists)
# remove data in bins where sensitivity exceeds some threshold
blinding_threshold = kwargs.get("blinding_threshold", None)
if blinding_threshold:
hists = blind_sensitive_bins(hists, config_inst, blinding_threshold)
# density scaling per bin
if density:
hists = apply_density(hists, density)
if len(shift_insts) == 1:
# when there is exactly one shift bin, we can remove the shift axis
hists = remove_residual_axis(hists, "shift", select_value=shift_insts[0].name)
else:
# remove shift axis of histograms that are not to be stacked
unstacked_hists = {
proc_inst: h
for proc_inst, h in hists.items()
if proc_inst.is_mc and getattr(proc_inst, "unstack", False)
}
hists |= remove_residual_axis(unstacked_hists, "shift", select_value="nominal")
# prepare the plot config
plot_config = prepare_stack_plot_config(
hists,
shape_norm=shape_norm,
shift_insts=shift_insts,
**kwargs,
)
# prepare and update the style config
default_style_config = prepare_style_config(
config_inst,
category_inst,
variable_inst,
density,
shape_norm,
yscale,
)
style_config = law.util.merge_dicts(
default_style_config,
process_style_config,
variable_style_config[variable_inst],
style_config,
deep=True,
)
# additional, plot function specific changes
if shape_norm:
style_config["ax_cfg"]["ylabel"] = "Normalized entries"
return plot_all(plot_config, style_config, **kwargs)
[docs]
def plot_variable_variants(
hists: OrderedDict,
config_inst: od.Config,
category_inst: od.Category,
variable_insts: list[od.Variable],
style_config: dict | None = None,
density: bool | None = False,
shape_norm: bool = False,
yscale: str | None = None,
hide_stat_errors: bool | None = None,
variable_settings: dict | None = None,
**kwargs,
) -> plt.Figure:
"""
TODO.
"""
hists = remove_residual_axis(hists, "shift")
variable_inst = variable_insts[0]
hists = apply_variable_settings(hists, variable_insts, variable_settings)
if density:
hists = apply_density(hists, density)
plot_config = OrderedDict()
# for updating labels of individual selector steps
selector_step_labels = config_inst.x("selector_step_labels", {})
# add hists
for label, h in hists.items():
norm = sum(h.values()) if shape_norm else 1
plot_config[f"hist_{label}"] = plot_cfg = {
"method": "draw_hist",
"hist": h,
"kwargs": {
"norm": norm,
"label": selector_step_labels.get(label, label),
},
"ratio_kwargs": {
"norm": hists["Initial"].values(),
},
}
if hide_stat_errors:
for key in ("kwargs", "ratio_kwargs"):
if key in plot_cfg:
plot_cfg[key]["yerr"] = None
# setup style config
default_style_config = prepare_style_config(
config_inst,
category_inst,
variable_inst,
density,
shape_norm,
yscale,
)
# plot-function specific changes
default_style_config["rax_cfg"]["ylim"] = (0., 1.1)
default_style_config["rax_cfg"]["ylabel"] = "Step / Initial"
style_config = law.util.merge_dicts(default_style_config, style_config, deep=True)
if shape_norm:
style_config["ax_cfg"]["ylabel"] = "Normalized entries"
return plot_all(plot_config, style_config, **kwargs)
[docs]
def plot_shifted_variable(
hists: OrderedDict,
config_inst: od.Config,
category_inst: od.Category,
variable_insts: list[od.Variable],
shift_insts: list[od.Shift] | None,
style_config: dict | None = None,
density: bool | None = False,
shape_norm: bool = False,
yscale: str | None = None,
hide_stat_errors: bool | None = None,
legend_title: str | None = None,
process_settings: dict | None = None,
variable_settings: dict | None = None,
**kwargs,
) -> plt.Figure:
"""
TODO.
"""
variable_inst = variable_insts[0]
hists, process_style_config = apply_process_settings(hists, process_settings)
hists, variable_style_config = apply_variable_settings(hists, variable_insts, variable_settings)
hists = apply_process_scaling(hists)
if density:
hists = apply_density(hists, density)
# add missing shifts to all histograms
all_shifts = set.union(*[set(h.axes["shift"]) for h in hists.values()])
for h in hists.values():
add_missing_shifts(h, all_shifts, str_axis="shift", nominal_bin="nominal")
# create the sum of histograms over all processes
h_sum = sum(list(hists.values())[1:], list(hists.values())[0].copy())
# setup plotting configs
plot_config = {}
colors = {
"nominal": "black",
"up": "red",
"down": "blue",
}
for i, shift_name in enumerate(h_sum.axes["shift"]):
shift_inst = config_inst.get_shift(shift_name)
h = h_sum[{"shift": hist.loc(shift_name)}]
# assuming `nominal` always has shift id 0
ratio_norm = h_sum[{"shift": hist.loc("nominal")}].values()
diff = sum(h.values()) / sum(ratio_norm) - 1
label = shift_inst.label
if not shift_inst.is_nominal:
label += " ({0:+.2f}%)".format(diff * 100)
plot_config[shift_inst.name] = plot_cfg = {
"method": "draw_hist",
"hist": h,
"kwargs": {
"norm": sum(h.values()) if shape_norm else 1,
"label": label,
"color": colors[shift_inst.direction],
},
"ratio_kwargs": {
"norm": ratio_norm,
"color": colors[shift_inst.direction],
},
}
if hide_stat_errors:
for key in ("kwargs", "ratio_kwargs"):
if key in plot_cfg:
plot_cfg[key]["yerr"] = None
# legend title setting
if not legend_title and len(hists) == 1:
# use process label as default if 1 process
process_inst = list(hists.keys())[0]
legend_title = process_inst.label
if not yscale:
yscale = "log" if variable_inst.log_y else "linear"
default_style_config = prepare_style_config(
config_inst,
category_inst,
variable_inst,
density,
shape_norm,
yscale,
)
default_style_config["rax_cfg"]["ylim"] = (0.25, 1.75)
default_style_config["rax_cfg"]["ylabel"] = "Ratio"
if legend_title:
default_style_config["legend_cfg"]["title"] = legend_title
style_config = law.util.merge_dicts(
default_style_config,
process_style_config,
variable_style_config[variable_inst],
style_config,
deep=True,
)
if shape_norm:
style_config["ax_cfg"]["ylabel"] = "Normalized entries"
return plot_all(plot_config, style_config, **kwargs)
[docs]
def plot_cutflow(
hists: OrderedDict,
config_inst: od.Config,
category_inst: od.Category,
style_config: dict | None = None,
density: bool | None = False,
shape_norm: bool = False,
yscale: str | None = None,
process_settings: dict | None = None,
**kwargs,
) -> plt.Figure:
"""
TODO.
"""
hists = remove_residual_axis(hists, "shift")
hists, process_style_config = apply_process_settings(hists, process_settings)
hists = apply_process_scaling(hists)
if density:
hists = apply_density(hists, density)
hists = hists_merge_cutflow_steps(hists)
# setup plotting config
plot_config = prepare_stack_plot_config(hists, shape_norm=shape_norm, **kwargs)
if shape_norm:
# switch normalization to normalizing to `initial step` bin
for key in list(plot_config):
item = plot_config[key]
h_key = item["hist"]
if isinstance(h_key, Iterable):
norm = sum(h[{"step": "Initial"}].value for h in h_key)
else:
norm = h_key[{"step": "Initial"}].value
item["kwargs"]["norm"] = norm
# update xticklabels based on config
xticklabels = []
selector_step_labels = config_inst.x("selector_step_labels", {})
selector_steps = list(hists[list(hists.keys())[0]].axes["step"])
for step in selector_steps:
xticklabels.append(selector_step_labels.get(step, step))
# setup style config
if not yscale:
yscale = "linear"
# build the label from category
cat_label = join_labels(category_inst.label)
default_style_config = {
"ax_cfg": {
"ylabel": "Selection efficiency" if shape_norm else "Selection yield",
"xlabel": "Selection step",
"xticklabels": xticklabels,
"yscale": yscale,
},
"legend_cfg": {
"loc": "upper right",
},
"annotate_cfg": {"text": cat_label or ""},
"cms_label_cfg": {
"lumi": round(0.001 * config_inst.x.luminosity.get("nominal"), 2), # /pb -> /fb
"com": config_inst.campaign.ecm,
},
}
style_config = law.util.merge_dicts(default_style_config, process_style_config, style_config, deep=True)
# ratio plot not used here; set `skip_ratio` to True
kwargs["skip_ratio"] = True
fig, (ax,) = plot_all(plot_config, style_config, **kwargs)
ax.set_xticklabels(xticklabels, rotation=45, ha="right")
return fig, (ax,)
[docs]
def plot_profile(
hists: OrderedDict[od.Process, hist.Hist],
config_inst: od.Config,
category_inst: od.Category,
variable_insts: list[od.Variable],
style_config: dict | None = None,
density: bool | None = False,
yscale: str | None = "",
hide_stat_errors: bool | None = None,
process_settings: dict | None = None,
variable_settings: dict | None = None,
skip_base_distribution: bool = False,
base_distribution_yscale: str = "linear",
skip_variations: bool = False,
**kwargs,
) -> plt.Figure:
"""
Takes 2-dimensional histograms as an input and profiles over the second axis.
This task adds two custom parameters, *skip_base_distribution* and *base_distribution_yscale*,
that can be selected on command-line via the --general-settings parameter
Exemplary task call:
.. code-block:: bash
law run cf.PlotVariables1D --version prod1 --processes st_tchannel_t \
--variables jet1_pt-jet2_pt \
--plot-function columnflow.plotting.plot_functions_1d.plot_profile
:param skip_base_distribution: whether to skip adding distributions of the non-profiled histogram to the plot
:param base_distribution_yscale: yscale of the base distributions
:param skip_variations: whether to skip adding the up and down variation of the profile plot
"""
if len(variable_insts) != 2:
raise Exception("The plot_profile function can only be used for 2-dimensional input histograms.")
# remove shift axis from histograms
hists = remove_residual_axis(hists, "shift")
hists, process_style_config = apply_process_settings(hists, process_settings)
hists, variable_style_config = apply_variable_settings(hists, variable_insts, variable_settings)
hists = apply_process_scaling(hists)
if density:
hists = apply_density(hists, density)
# process histograms to profiled and reduced histograms
profiled_hists, reduced_hists = OrderedDict(), OrderedDict()
for process_inst, h_in in hists.items():
# always set "unstack" to True since we cannot stack profiled histograms
# NOTE: to add multiple processes into one profile, you can define a new process
process_inst.unstack = True
profiled_hists[process_inst] = get_profile_variations(h_in, axis=1)
reduced_hists[process_inst] = h_in[{h_in.axes[1].name: sum}]
# setup plot config
plot_config = OrderedDict()
default_colors = plt.rcParams["axes.prop_cycle"].by_key()["color"]
for proc_inst, h in profiled_hists.items():
# set the default process color via the default matplotlib colors for consistency
proc_inst.color = proc_inst.color or default_colors.pop(0)
# add profiles to plot config
plot_config[f"profile_{proc_inst.name}"] = plot_cfg = {
"method": "draw_profile",
"hist": h["nominal"],
"kwargs": {
"label": proc_inst.label,
"color": proc_inst.color,
"histtype": "step",
},
}
if not skip_variations:
for variation in ("up", "down"):
plot_config[f"profile_{proc_inst.name}_{variation}"] = {
"method": "draw_profile",
"hist": h[variation],
"kwargs": {
"color": proc_inst.color,
"histtype": "step",
"linestyle": "dashed",
"yerr": None, # always disable yerr
},
}
if hide_stat_errors:
for key in ("kwargs", "ratio_kwargs"):
if key in plot_cfg:
plot_cfg[key]["yerr"] = None
default_style_config = prepare_style_config(
config_inst,
category_inst,
variable_insts[0],
density=density,
yscale=yscale,
xtick_rotation=kwargs.get("rotate_xticks", None),
)
default_style_config["ax_cfg"]["ylabel"] = f"profiled {variable_insts[1].x_title}"
style_config = law.util.merge_dicts(
default_style_config,
process_style_config,
variable_style_config[variable_insts[0]],
style_config,
deep=True,
)
# ratio plot not used here; set `skip_ratio` to True
kwargs["skip_ratio"] = True
# create the default plot
fig, (ax,) = plot_all(plot_config, style_config, **kwargs)
# add base distributions only if requested
if skip_base_distribution:
return fig, (ax,)
# add secondary axis for the background distribution
ax1 = ax.twinx()
for proc_inst, h in reduced_hists.items():
h = h / sum(h.values())
plot_kwargs = {
"ax": ax1,
"color": proc_inst.color,
"histtype": "fill",
"alpha": 0.25,
}
h.plot1d(**plot_kwargs)
log_y = base_distribution_yscale == "log"
ax1_ymin = ax1.get_ylim()[1] / 10**kwargs.get("magnitudes", 4) if log_y else 0.0000001
ax1_ymax = get_position(
ax1_ymin,
ax1.get_ylim()[1],
factor=1 / (1 - kwargs.get("whitespace_fraction", 0.3)),
logscale=log_y,
)
ax1.set(
ylim=(ax1_ymin, ax1_ymax),
ylabel="Normalized entries",
yscale=base_distribution_yscale,
)
plt.tight_layout()
return fig, (ax, ax1)