from copy import deepcopy
import numpy as np
import pandas as pd
from pyam import filter_by_meta
from pyam.exceptions import deprecation_warning
from pyam.str import is_str
from pyam.utils import META_IDX, is_list_like
[docs]
class Statistics:
"""This class generates descriptive statistics of timeseries data
Parameters
----------
df : IamDataFrame
an IamDataFrame from which to retrieve meta indicators for grouping
or filtering
groupby : str or dict
a column of `df.meta` to be used for `groupby`
or a dictionary of `{column: list}`, where `list` is used for ordering
filters : list of tuples
arguments for filtering and describing, either `((index, dict)` or
`((index[0], index[1]), dict)`; when also using `groupby`, index must
have length 2.
percentiles : list-like of numbers, optional
The percentiles to get from :meth:`pandas.DataFrame.describe()`.
All should fall between 0 and 1. The default is `[.25, .5, .75]`,
which returns the 25th, 50th, and 75th percentiles.
"""
def __init__( # noqa: C901
self,
df,
groupby=None,
filters=None,
rows=False,
percentiles=[0.05, 0.25, 0.5, 0.75, 0.95],
):
self.df = df
self.idx_depth = None
# assing `groupby` settings and check that specifications are valid
self.col = None
self.groupby = None
if is_str(groupby):
self.col = groupby
self.groupby = {groupby: None}
elif isinstance(groupby, dict) and len(groupby) == 1:
self.col = list(groupby.keys())[0]
self.groupby = groupby
self.idx_depth = 2
elif groupby is not None:
raise ValueError(f"arg `{groupby}` not valid `groupby`")
if self.col is not None and self.col not in df.meta.columns:
raise ValueError(f"column `{self.col}` not in `df.meta`")
# if neither groupby nor filters is given, use filters to describe all
# and assume that rows are used
if groupby is None and filters is None:
self.filters = [("", {})]
rows = True
else:
self.filters = filters if filters is not None else []
# set lists to sort index and subindex
self._idx = [] if self.col is None else [self.col]
self._sub_idx = (
self.groupby[self.col] or self.df[self.col].unique()
if self.col is not None
else []
)
self._headers, self._subheaders = ([], [])
# assing `filters` settings and check that specifications are valid
for idx, _filter in self.filters:
# check that index in tuple is valid
if is_str(idx):
self._add_to_index(idx)
else:
if not (
isinstance(idx, tuple)
and len(idx) == 2
and is_str(idx[0])
or not is_str(idx[1])
):
raise ValueError(f"`{idx}` is not a valid index")
self._add_to_index(idx[0], idx[1])
# check that filters in tuple are valid
if not isinstance(_filter, dict):
raise ValueError(f"`{_filter}` is not a valid filter")
elif not (set(_filter) - set(META_IDX)).issubset(df.meta):
raise ValueError(
"column `{}` not in `df.meta`".format(
set(_filter) - set(META_IDX) - set(df.meta)
)
)
self.stats = None
self.rows = [] if rows else None
# percentiles for passing to `pandas.describe()`
self.percentiles = list(percentiles)
self._describe_cols = (
["count", "mean", "std", "min"]
+ [f"{i:.0%}" for i in self.percentiles]
+ ["max"]
)
def _add_to_index(self, idx, sub_idx=None):
# assign index depth if not set
if self.idx_depth is None:
self.idx_depth = 1 if sub_idx is None else 2
# check that index matches depth
if self.groupby is not None and sub_idx is None:
msg = "if `groupby` is used, index `{}` must have format `{}`"
raise ValueError(msg.format(idx, "(idx0, idx1)"))
if self.idx_depth == 1 and sub_idx is not None:
raise ValueError(f"index depth set to 1, found `({idx}, {sub_idx})`")
if self.idx_depth == 2 and sub_idx is None:
raise ValueError(f"index depth set to 2, found `({idx})`")
# append to lists for sorting index
if idx not in self._idx:
self._idx.append(idx)
if self.idx_depth == 2 and sub_idx not in self._sub_idx:
self._sub_idx.append(sub_idx)
def _add_to_header(self, header, subheader):
if header not in self._headers:
self._headers.append(header)
if is_list_like(subheader):
for s in subheader:
if s not in self._subheaders:
self._subheaders.append(s)
elif subheader not in self._subheaders:
self._subheaders.append(subheader)
[docs]
def add(self, data, header, row=None, subheader=None): # noqa: C901
"""Filter 'data' by arguments of this Statistics instance,
Apply :meth:`pandas.DataFrame.describe()` and format the statistics
Parameters
----------
data : pandas.DataFrame or pandas.Series
data for which summary statistics should be computed
header : str
column name for descriptive statistics
row : str
row name for descriptive statistics
(required if :class:`Statistics(rows=True) <Statistics>`)
subheader : str, optional
column name (level=1) if data is a unnamed :class:`pandas.Series`
"""
# verify validity of specifications
if self.rows is not None and row is None:
raise ValueError("row specification required")
if self.rows is None and row is not None:
raise ValueError("row arg illegal for this `Statistics` instance")
if isinstance(data, pd.Series):
if subheader is not None:
data.name = subheader
elif data.name is None:
msg = "`data` must be named `pd.Series` or provide `subheader`"
raise ValueError(msg)
data = pd.DataFrame(data)
if self.rows is not None and row not in self.rows:
self.rows.append(row)
_stats = None
# describe with groupby feature
if self.groupby is not None:
filter_args = dict(data=data, df=self.df, join_meta=True)
filter_args.update(self.groupby)
_stats = (
filter_by_meta(**filter_args)
.groupby(self.col)
.describe(percentiles=self.percentiles)
)
_stats = pd.concat([_stats], keys=[self.col], names=[""], axis=0)
if self.rows:
_stats["row"] = row
_stats.set_index("row", append=True, inplace=True)
_stats.index.names = [""] * 3 if self.rows else [""] * 2
# describe with filter feature
for idx, _filter in self.filters:
filter_args = dict(data=data, df=self.df)
filter_args.update(_filter)
_stats_f = filter_by_meta(**filter_args).describe(
percentiles=self.percentiles
)
_stats_f = pd.DataFrame(_stats_f.unstack()).T
if self.idx_depth == 1:
levels = [[idx]]
else:
levels = [[idx[0]], [idx[1]]]
lvls, lbls = (
(levels, [[0]] * self.idx_depth)
if not self.rows
else (levels + [[row]], [[0]] * (self.idx_depth + 1))
)
_stats_f.index = pd.MultiIndex(levels=lvls, codes=lbls)
_stats = _stats_f if _stats is None else pd.concat([_stats, _stats_f])
# add header
_stats = pd.concat([_stats], keys=[header], names=[""], axis=1)
_stats.index.names = [None] * len(_stats.index.names)
subheader = _stats.columns.get_level_values(1).unique()
self._add_to_header(header, subheader)
# set statistics
if self.stats is None:
self.stats = _stats
else:
self.stats = _stats.combine_first(self.stats)
[docs]
def reindex(self, copy=True):
"""Reindex the summary statistics dataframe"""
ret = deepcopy(self) if copy else self
ret.stats = ret.stats.reindex(index=ret._idx, level=0)
if ret.idx_depth == 2:
ret.stats = ret.stats.reindex(index=ret._sub_idx, level=1)
if ret.rows is not None:
ret.stats = ret.stats.reindex(index=ret.rows, level=ret.idx_depth)
ret.stats = ret.stats.reindex(columns=ret._headers, level=0)
ret.stats = ret.stats.reindex(columns=ret._subheaders, level=1)
ret.stats = ret.stats.reindex(columns=ret._describe_cols, level=2)
if copy:
return ret
[docs]
def summarize(
self,
center="mean",
*,
limits=None,
fullrange=None,
interquartile=None,
custom_format="{:.2f}",
):
"""Format the compiled statistics to a concise string output
Parameters
----------
center : str, optional
The 'center' of the summary: 'mean', 'median' (50th percentile).
limits: str, optional
The upper and lower limits of the shown range: 'full range',
'interquartile', 'central 90%', defaults to 'full range'.
fullrange : bool, optional, deprecated
Return full range of data if True or `fullrange`, `interquartile`
and `format_spec` are None.
interquartile : bool, optional, deprecated
Return interquartile range if True.
custom_format : formatting specifications
"""
# call `reindex()` to reorder index and columns before summarizing
self.reindex(copy=False)
# TODO: deprecated, remove "50%" for release >= 4.1 to streamline signature
if center == "50%":
deprecation_warning(
"use `center='median'` instead.",
"Using `center='50%'",
)
center = "median"
# TODO: remove deprecated args for release >= 4.1, remove all code below
if fullrange is not None and interquartile is not None:
raise ValueError(
"Cannot use `interquartile` and `fullrange`, use `limits` instead."
)
for arg, name, value in [
(fullrange, "fullrange", "full range"),
(interquartile, "interquartile", "interquartile"),
]:
if arg is not None:
if limits is not None:
raise ValueError(
f"Cannot use `{name}` with `limits`, "
f"use `limits='{value}'` instead."
)
deprecation_warning(
f"use `limits='{value}'` instead.",
f"The argument `{name}`",
)
limits = value
if limits is None:
limits = "full range"
# TODO remove all code up to here, set `limits='full range'` as default
if limits not in ["full range", "interquartile", "central 90%"]:
raise ValueError(f"Invalid argument for `limits`: {limits}")
summary = self.stats.apply(
format_rows,
center=center,
limits=limits,
custom_format=custom_format,
axis=1,
raw=False,
)
summary[("count", "")] = summary[("count", "")].map(int)
return summary
def format_rows(row, center, limits, custom_format="{:.2f}"):
"""Format a row with `describe()` columns to a concise string"""
legend = center
if limits == "full range":
legend += " (max, min)"
upper, lower = ("max", "min")
elif limits == "interquartile":
legend += " (p75, p25)"
upper, lower = ("75%", "25%")
elif limits == "central 90%":
legend += " (p95, p5)"
upper, lower = ("95%", "5%")
row_index = row.index.droplevel(2).drop_duplicates()
ret_index = pd.MultiIndex.from_tuples([("count", "")]).append(row_index)
ret_index.names = [None, legend]
ret = pd.Series(index=ret_index, dtype=object)
row = row.sort_index()
# get maximum of `count` and write to first entry of return series
count = max(
[i for i in row.loc[(slice(None), slice(None), "count")] if not np.isnan(i)]
)
ret.loc[("count", "")] = count
# format `describe()` columns to string output
center = "50%" if center == "median" else center
for i in row_index:
x = row.loc[i]
_count = x["count"]
if np.isnan(_count) or _count == 0:
s = ""
elif _count > 1:
s = "{f} ({f}, {f})".format(f=custom_format).format(
x[center], x[upper], x[lower]
)
elif _count == 1:
s = f"{custom_format}".format(x[center])
# add count of this section as `[]` if different from count_max
if 0 < _count < count:
s += f" [{_count:.0f}]"
ret.loc[i] = s
return ret