# Copyright 2018 John Harwell, All rights reserved.
#
# SPDX-License-Identifier: MIT
#
"""
Linegraph for summarizing the results of a :term:`Batch Experiment`.
Graphs one datapoint per :term:`Experiment`.
"""
# Core packages
import typing as tp
import pathlib
import logging
# 3rd party packages
import polars as pl
import holoviews as hv
# Project packages
from sierra.core import config, utils, storage, models
from . import pathset, graphutils
_logger = logging.getLogger(__name__)
[docs]
def generate( # noqa: PLR0913,PLR0917
pathset: pathset.PathSet,
input_stem: str,
output_stem: str,
medium: str,
title: str,
xlabel: str,
ylabel: str,
backend: str,
legend: list[str],
xticks: list[float],
stats_center: str,
stats_spread: str,
*,
xticklabels: tp.Optional[list[str]] = None,
large_text: bool = False,
logyscale: bool = False,
) -> bool:
"""Generate a linegraph from a :term:`Batch Summary Data` file.
Possibly shows the 95% confidence interval or box and whisker plots,
according to configuration.
Attributes:
paths: Set of run-time tree paths for the batch experiment.
input_stem: Stem of the :term:`Batch Summary Data` file to generate a
graph from.
output_fpath: The absolute path to the output image file to save
generated graph to.
title: Graph title.
xlabel: X-label for graph.
ylabel: Y-label for graph.
backend: The holoviews backend to use.
xticks: The xticks for the graph.
xticklabels: The xtick labels for the graph (can be different than the
xticks; e.g., if the xticxs are 1-10 for categorical data,
then then labels would be the categories).
large_text: Should the labels, ticks, and titles be large, or regular
size?
legend: Legend for graph.
logyscale: Should the Y axis be in the log2 domain ?
stats_spread: The type of spread statistics to include on the graph (from
``--spread``).
stats_center: The measure of centeral tendency to use as the main data
input. (from ``--center``).
model_root: The absolute path to the ``models/`` directory for the batch
experiment.
"""
hv.extension(backend, inline=False, logo=False)
ofile_ext = graphutils.ofile_ext(backend)
input_fpath = pathset.input_root / (
input_stem + config.STATS[stats_center].spreads["none"].exts[stats_center]
)
output_fpath = pathset.output_root / f"SM-{output_stem}.{ofile_ext}"
if not utils.path_exists(input_fpath):
_logger.debug(
"Not generating <batchroot>/%s: <batchroot>/%s does not exist",
output_fpath.relative_to(pathset.batchroot),
input_fpath.relative_to(pathset.batchroot),
)
return False
text_size = (
config.GRAPHS["text_size_large"]
if large_text
else config.GRAPHS["text_size_small"]
)
df = storage.df_read(input_fpath, medium)
# Column 0 is the 'Experiment ID' index, which we don't want included as
# a vdim
cols = df.columns[1:]
df = df.with_columns(pl.Series("xticks", xticks))
# Convert to pandas for HoloViews compatibility
df_pd = df.to_pandas()
dataset = hv.Dataset(data=df_pd.reset_index(), kdims=["xticks"], vdims=cols)
assert len(df) == len(
xticks
), "Length mismatch between xticks,# data points: {} vs {}".format(
len(xticks), len(df)
)
model_info = _read_model_info(pathset.model_root, input_stem, medium, xticks)
# Add statistics according to configuration
stat_dfs = graphutils.read_spread_stats(
stats_center, stats_spread, pathset.input_root, input_stem, medium
)
vdim_color = graphutils.build_color_map(dataset, legend)
plot = graphutils.plot_stats(
dataset,
stats_center,
stats_spread,
stat_dfs,
backend=backend,
vdim_color=vdim_color,
)
# Add legend
plot.opts(legend_position="bottom")
# Plot lines after stats so they show on top
plot *= _plot_lines(dataset, model_info, legend, backend)
# Add X,Y labels
plot.opts(ylabel=ylabel, xlabel=xlabel)
# Configure ticks (must be last so not overwritten by what you get from
# plotting the lines)
plot = _plot_ticks(plot, logyscale, xticks, xticklabels)
# Set fontsizes
plot.opts(
fontsize={
"title": text_size["title"],
"labels": text_size["xyz_label"],
"ticks": text_size["tick_label"],
"legend": text_size["legend_label"],
},
)
# Add title
plot.opts(title=title)
graphutils.plot_save(plot, output_fpath, backend)
_logger.debug(
"Graph written to <batchroot>/%s", output_fpath.relative_to(pathset.batchroot)
)
return True
def _plot_lines(
dataset: hv.Dataset,
model_info: models.ModelInfo,
legend: list[str],
backend: str,
) -> hv.NdOverlay:
# Plot the curve(s)
plot = hv.Overlay(
[
hv.Curve(
dataset,
kdims=dataset.kdims[0],
vdims=vdim,
label=legend[dataset.vdims.index(vdim)],
)
for vdim in dataset.vdims
]
)
# Plot the points for each curve
plot *= hv.Overlay(
[hv.Points((dataset[dataset.kdims[0]], dataset[v])) for v in dataset.vdims]
)
if model_info.dataset:
# TODO: This currently only works for a single model being put onto a
# summary line graph.
curve_style = tp.cast(dict[str, tp.Any], config.GRAPHS["curve_dashed"])
plot *= hv.Overlay(
[
hv.Curve(
model_info.dataset,
model_info.dataset.kdims[0],
vdim.name,
label=model_info.legend[model_info.dataset.vdims.index(vdim)],
).opts(**curve_style[backend])
for vdim in model_info.dataset.vdims
]
)
# Plot the points for each curve
plot *= hv.Overlay(
[
hv.Points(
(
model_info.dataset[model_info.dataset.kdims[0]],
model_info.dataset[v],
)
)
for v in model_info.dataset.vdims
if len(model_info.dataset[v]) <= 50
]
)
return plot
def _plot_ticks(
plot: hv.NdOverlay,
logyscale: bool,
xticks: list[float],
xticklabels: tp.Optional[list[str]],
) -> hv.NdOverlay:
if logyscale:
plot.opts(logy=True)
# For ordered, qualitative data
if xticklabels is not None:
plot.opts(xticks=list(zip(xticks, xticklabels)), xrotation=90)
return plot
# 2024/09/13 [JRH]: The union is for compatability with type checkers in
# python {3.8,3.11}.
def _read_model_info(
model_root: tp.Optional[pathlib.Path],
input_stem: str,
medium: str,
xticks: list[float],
) -> models.ModelInfo:
if model_root is None:
return models.ModelInfo()
_logger.trace("Model root='%s'", model_root)
exts = config.MODELS_EXT
modelf = model_root / (input_stem + exts["model"])
legendf = model_root / (input_stem + exts["legend"])
if not utils.path_exists(modelf):
_logger.trace(
"No model file=<batch_model_root>/%s found",
modelf.relative_to(model_root),
)
return models.ModelInfo()
info = models.ModelInfo()
df = storage.df_read(modelf, medium)
# Column 0 is the 'Experiment ID' index, which we don't want included as
# a vdim
cols = df.columns[1:]
df = df.with_columns(pl.Series("xticks", xticks))
# Convert to pandas for HoloViews compatibility
df_pd = df.to_pandas()
info.dataset = hv.Dataset(data=df_pd.reset_index(), kdims=["xticks"], vdims=cols)
with utils.utf8open(legendf, "r") as f:
info.legend = f.read().splitlines()
_logger.trace(
"Loaded model='%s',legend='%s'",
modelf.relative_to(model_root),
legendf.relative_to(model_root),
)
return info
__all__ = ["generate"]