From 4bfcec0ae0ae9815e67b283e88acf004e989cac2 Mon Sep 17 00:00:00 2001 From: Daniel Huppmann Date: Wed, 2 Aug 2023 15:26:42 +0200 Subject: [PATCH] Excise "exclude" column from meta and add a own attribute (#759) --- RELEASE_NOTES.md | 15 +++ docs/tutorials/pyam_first_steps.ipynb | 18 +-- pyam/_debiasing.py | 2 +- pyam/core.py | 184 +++++++++++++++++--------- pyam/iiasa.py | 4 + pyam/logging.py | 5 +- pyam/testing.py | 6 + pyam/utils.py | 24 +++- tests/data/exclude_meta_sheet.xlsx | Bin 0 -> 11795 bytes tests/test_core.py | 40 +++--- tests/test_feature_aggregate.py | 2 +- tests/test_feature_append_concat.py | 13 +- tests/test_feature_rename.py | 27 ++-- tests/test_feature_validation.py | 36 ++--- tests/test_filter.py | 22 +++ tests/test_io.py | 8 ++ 16 files changed, 277 insertions(+), 129 deletions(-) create mode 100644 tests/data/exclude_meta_sheet.xlsx diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index a66fc49f3..171057e9b 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,20 @@ # Next Release +The next release must bump the major version number. + +## API changes + +The column *exclude* of the `meta` attribute was refacored to a new attribute `exclude`. +All validation methods are refactored such that the argument `exclude_on_fail` changes +this new attribute (see PR [#759](https://github.com/IAMconsortium/pyam/pull/759)). + +The term "exclude" is now an illegal column name for (timeseries) data and meta tables. +When importing an xlsx file created with pyam < 2.0, which has an "exclude" column in +"meta", that column is moved to the new exclude attribute with a log message. + +## Individual updates + +- [#759](https://github.com/IAMconsortium/pyam/pull/759) Excise "exclude" column from meta and add a own attribute - [#747](https://github.com/IAMconsortium/pyam/pull/747) Drop support for Python 3.7 #747 # Release v1.9.0 diff --git a/docs/tutorials/pyam_first_steps.ipynb b/docs/tutorials/pyam_first_steps.ipynb index 5b1e3d04f..bc15fb80d 100644 --- a/docs/tutorials/pyam_first_steps.ipynb +++ b/docs/tutorials/pyam_first_steps.ipynb @@ -101,9 +101,9 @@ "\n", "
\n", "\n", - "If you haven't cloned the **pyam** GitHub repository to your machine, you can download the file\n", - "from the folder [doc/source/tutorials](https://github.com/IAMconsortium/pyam/tree/master/doc/source/tutorials). \n", - "Make sure to place the file in the same folder as this notebook.\n", + "If you haven't cloned the **pyam** GitHub repository to your machine, you can download the data file\n", + "from the folder [docs/tutorials](https://github.com/IAMconsortium/pyam/tree/main/docs/tutorials). \n", + "Make sure to place the data file in the same folder as this notebook.\n", "\n", "
" ] @@ -465,9 +465,9 @@ "When analyzing scenario results, it is often useful to check whether certain timeseries data exist or the values are within a specific range.\n", "For example, it may make sense to ensure that reported data for historical periods are close to established reference data or that near-term developments are reasonable.\n", "\n", - "Before diving into the diagnostics and validation features, we need to briefly introduce the 'meta' table.\n", + "Before diving into the diagnostics and validation features, we need to briefly introduce the **meta** attribute.\n", "This attribute of an **IamDataFrame** is a [pandas.DataFrame](https://pandas.pydata.org/pandas-docs/stable/generated/pandas.DataFrame.html), which can be used to store categorization information and quantitative indicators of each model-scenario.\n", - "Per default, a new **IamDataFrame** will contain a column `exclude`, which is set to `False` for all model-scenarios.\n", + "In addition, an **IamDataFrame** has an attribubte **exclude**, which is a [pandas.Series](https://pandas.pydata.org/pandas-docs/stable/generated/pandas.DataFrame.html) set to `False` for all model-scenario combinations.\n", "\n", "The next cell shows the first 10 rows of the 'meta' table." ] @@ -518,7 +518,7 @@ "metadata": {}, "outputs": [], "source": [ - "df_world.require_variable(variable='Primary Energy')" + "df_world.require_data(variable='Primary Energy')" ] }, { @@ -527,7 +527,7 @@ "metadata": {}, "outputs": [], "source": [ - "df_world.require_variable(variable='Primary Energy', year=2100)" + "df_world.require_data(variable='Primary Energy', year=2100)" ] }, { @@ -864,8 +864,8 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "As a last step of this illustrative example, we again display the first 10 rows of the 'meta' table for the scenarios in the **IamDataFrame**.\n", - "In addition to the `exclude` column seen in cell 20, this table now also includes columns with the three quantitative indicators." + "As a last step of this illustrative example, we again display the first 10 rows of the **meta** attribute of the **IamDataFrame**.\n", + "The **meta** column seen in cell 20 now includes columns with the three quantitative indicators." ] }, { diff --git a/pyam/_debiasing.py b/pyam/_debiasing.py index 7378c8efe..f78c2d4a3 100644 --- a/pyam/_debiasing.py +++ b/pyam/_debiasing.py @@ -2,7 +2,7 @@ def _compute_bias(df, name, method, axis): """Internal implementation for computing bias weights""" if method == "count": # invert from the count to obtain the weighting factor - count = 1 / df.meta.groupby(axis).count().exclude + count = 1 / df.exclude.groupby(axis).count() count.name = name df.meta = df.meta.join(count, on=axis, how="outer") else: diff --git a/pyam/core.py b/pyam/core.py index 07ee79a76..7db9ec95f 100755 --- a/pyam/core.py +++ b/pyam/core.py @@ -30,6 +30,7 @@ read_pandas, format_data, merge_meta, + merge_exclude, find_depth, pattern_match, to_list, @@ -141,11 +142,12 @@ def _init(self, data, meta=None, index=DEFAULT_META_INDEX, **kwargs): # pop kwarg for meta_sheet_name (prior to reading data from file) meta_sheet = kwargs.pop("meta_sheet_name", "meta") - # if meta is given explicitly, verify that index matches - if meta is not None and not meta.index.names == index: - raise ValueError( - f"Incompatible `index={index}` with `meta` (index={meta.index.names})!" - ) + # if meta is given explicitly, verify that index and column names are valid + if meta is not None: + if not meta.index.names == index: + raise ValueError( + f"Incompatible `index={index}` with `meta.index={meta.index.names}`" + ) # try casting to Path if file-like is string or LocalPath or pytest.LocalPath try: @@ -177,8 +179,9 @@ def _init(self, data, meta=None, index=DEFAULT_META_INDEX, **kwargs): self._data, index, self.time_col, self.extra_cols = _data # define `meta` dataframe for categorization & quantitative indicators - self.meta = pd.DataFrame(index=_make_index(self._data, cols=index)) - self.reset_exclude() + _index = _make_index(self._data, cols=index) + self.meta = pd.DataFrame(index=_index) + self.exclude = False # if given explicitly, merge meta dataframe after downselecting if meta is not None: @@ -291,18 +294,19 @@ def print_meta_row(m, t, lst): _lst = print_list(lst, n - len(m) - len(t) - 7) return f" {m} ({t}) {_lst}" - info += "\nMeta indicators:\n" - info += "\n".join( - [ - print_meta_row(m, t, self.meta[m].unique()) - for m, t in zip( - self.meta.columns[0:meta_rows], self.meta.dtypes[0:meta_rows] - ) - ] - ) - # print `...` if more than `meta_rows` columns - if len(self.meta.columns) > meta_rows: - info += "\n ..." + if len(self.meta.columns): + info += "\nMeta indicators:\n" + info += "\n".join( + [ + print_meta_row(m, t, self.meta[m].unique()) + for m, t in zip( + self.meta.columns[0:meta_rows], self.meta.dtypes[0:meta_rows] + ) + ] + ) + # print `...` if more than `meta_rows` columns + if len(self.meta.columns) > meta_rows: + info += "\n ..." # add info on size (optional) if memory_usage: @@ -452,6 +456,27 @@ def time_domain(self): return self._time_domain + @property + def exclude(self): + """Indicator for exclusion of scenarios, used by validation methods + + See Also + -------- + validate, require_data, check_aggregate, check_aggregate_region + + """ + return self._exclude + + @exclude.setter + def exclude(self, exclude): + """Indicator for scenario exclusion, used to validate with `exclude_on_fail`""" + if isinstance(exclude, bool): + self._exclude = pd.Series(exclude, index=self.meta.index) + else: + raise NotImplementedError( + f"Setting `exclude` must have a boolean, found: {exclude}" + ) + def copy(self): """Make a deepcopy of this object @@ -488,7 +513,11 @@ def equals(self, other): if not isinstance(other, IamDataFrame): raise ValueError("`other` is not an `IamDataFrame` instance") - if compare(self, other).empty and self.meta.equals(other.meta): + if ( + compare(self, other).empty + and self.meta.equals(other.meta) + and self.exclude.equals(other.exclude) + ): return True else: return False @@ -557,6 +586,7 @@ def append( # merge `meta` tables ret.meta = merge_meta(ret.meta, other.meta, ignore_meta_conflict) + ret._exclude = merge_exclude(ret._exclude, other._exclude, ignore_meta_conflict) # append other.data (verify integrity for no duplicates) _data = pd.concat([ret._data, other._data]) @@ -781,8 +811,10 @@ def timeseries(self, iamc_index=False): return s.unstack(level=self.time_col).rename_axis(None, axis=1) def reset_exclude(self): - """Reset exclusion assignment for all scenarios to `exclude: False`""" - self.meta["exclude"] = False + """Reset exclusion assignment for all scenarios to :attr:`exclude` = *False*""" + # TODO: deprecated, remove for release >= 2.1 + deprecation_warning("Use `IamDataFrame.exclude = False` instead.") + self.exclude = False def set_meta(self, meta, name=None, index=None): """Add meta indicators as pandas.Series, list or value (int/float/str) @@ -799,6 +831,11 @@ def set_meta(self, meta, name=None, index=None): index to be used for setting meta column (`['model', 'scenario']`) """ if isinstance(meta, pd.DataFrame): + if illegal_cols := [i for i in meta.columns if i in ILLEGAL_COLS]: + raise ValueError( + "Illegal columns in `meta`: '" + "', '".join(illegal_cols) + "'" + ) + if meta.index.names != self.meta.index.names: # catch Model, Scenario instead of model, scenario meta = meta.rename( @@ -815,9 +852,9 @@ def set_meta(self, meta, name=None, index=None): raise ValueError("Must pass a name or use a named pd.Series") name = name or meta.name if name in self.dimensions: - raise ValueError(f"Column {name} already exists in `data`!") + raise ValueError(f"Column '{name}' already exists in `data`.") if name in ILLEGAL_COLS: - raise ValueError(f"Name {name} is illegal for meta indicators!") + raise ValueError(f"Name '{name}' is illegal for meta indicators.") # check if meta has a valid index and use it for further workflow if ( @@ -948,12 +985,13 @@ def require_data( year : int or list of int, optional Required year(s). exclude_on_fail : bool, optional - Set *meta* indicator for scenarios failing validation as `exclude: True`. + If True, set :attr:`exclude` = *True* for all scenarios that do not satisfy + the criteria. Returns ------- - pd.DataFrame - A dataframe of the *index* of scenarios not satisfying the cr + :class:`pandas.DataFrame` or None + A dataframe of the *index* of scenarios not satisfying the criteria. """ # TODO: option to require values in certain ranges, see `_apply_criteria()` @@ -1010,13 +1048,15 @@ def require_variable(self, variable, unit=None, year=None, exclude_on_fail=False Parameters ---------- variable : str - required variable - unit : str, default None - name of unit (optional) - year : int or list, default None - check whether the variable exists for ANY of the years (if a list) - exclude_on_fail : bool, default False - flag scenarios missing the required variables as `exclude: True` + Required variable. + unit : str, optional + Name of unit (optional). + year : int or list, optional + Check whether the variable exists for ANY of the years (if a list). + exclude_on_fail : bool, optional + If True, set :attr:`exclude` = True for all scenarios that do not satisfy + the criteria. + """ # TODO: deprecated, remove for release >= 2.0 deprecation_warning("Use `df.require_data()` instead.") @@ -1044,8 +1084,7 @@ def require_variable(self, variable, unit=None, year=None, exclude_on_fail=False ) if exclude_on_fail: - self.meta.loc[idx, "exclude"] = True - msg += ", marked as `exclude: True` in `meta`" + self._exclude_on_fail(idx) logger.info(msg.format(n, variable)) return pd.DataFrame(index=idx).reset_index() @@ -1065,14 +1104,13 @@ def validate(self, criteria={}, exclude_on_fail=False): dictionary with variable keys and validation mappings ('up' and 'lo' for respective bounds, 'year' for years) exclude_on_fail : bool, optional - flag scenarios failing validation as `exclude: True` + If True, set :attr:`exclude` = *True* for all scenarios that do not satisfy + the criteria. Returns ------- - :class:`pandas.DataFrame` + :class:`pandas.DataFrame` or None All data points that do not satisfy the criteria. - None - If all scenarios satisfy the criteria. """ df = _apply_criteria(self._data, criteria, in_range=False) @@ -1139,8 +1177,9 @@ def rename( # changing index and data columns can cause model-scenario mismatch if any(i in mapping for i in meta_idx) and any(i in mapping for i in data_cols): - msg = "Renaming index and data columns simultaneously not supported!" - raise ValueError(msg) + raise NotImplementedError( + "Renaming index and data columns simultaneously is not supported." + ) # translate rename mapping to `filter()` arguments filters = {col: _from.keys() for col, _from in mapping.items()} @@ -1168,6 +1207,8 @@ def rename( if _index.duplicated().any(): raise ValueError(f"Renaming to non-unique {col} index!") ret.meta.index = _index.set_index(meta_idx).index + ret._exclude.index = ret.meta.index + elif col not in data_cols: raise ValueError(f"Renaming by {col} not supported!") _data_index = replace_index_values(_data_index, col, _mapping, rows) @@ -1414,7 +1455,8 @@ def check_aggregate( Method to use for aggregation, e.g. :any:`numpy.mean`, :any:`numpy.sum`, 'min', 'max'. exclude_on_fail : bool, optional - Flag scenarios failing validation as `exclude: True`. + If True, set :attr:`exclude` = *True* for all scenarios where the aggregate + does not match the aggregated components. multiplier : number, optional Multiplicative factor when comparing variable and sum of components. kwargs : Tolerance arguments for comparison of values @@ -1423,7 +1465,7 @@ def check_aggregate( Returns ------- :class:`pandas.DataFrame` or None - Data where variables and aggregate does not match. + Data where variables and aggregate does not match the aggregated components. """ # compute aggregate from components, return None if no components @@ -1554,7 +1596,8 @@ def check_aggregate_region( Variable to use as weight for the aggregation (currently only supported with `method='sum'`). exclude_on_fail : boolean, optional - Flag scenarios failing validation as `exclude: True`. + If True, set :attr:`exclude` = *True* for all scenarios where the aggregate + does not match the aggregated components. drop_negative_weights : bool, optional Removes any aggregated values that are computed using negative weights kwargs : Tolerance arguments for comparison of values @@ -1782,17 +1825,16 @@ def check_internal_consistency(self, components=False, **kwargs): ] def _exclude_on_fail(self, df): - """Assign a selection of scenarios as `exclude: True` in meta""" + """Assign a selection of scenarios as `exclude: True`""" idx = ( df if isinstance(df, pd.MultiIndex) else _make_index(df, cols=self.index.names) ) - self.meta.loc[idx, "exclude"] = True + self.exclude[idx] = True + n = len(idx) logger.info( - "{} non-valid scenario{} will be excluded".format( - len(idx), "" if len(idx) == 1 else "s" - ) + f"{n} scenario{s(n)} failed validation and will be set as `exclude=True`." ) def slice(self, keep=True, **kwargs): @@ -1873,6 +1915,8 @@ def filter(self, keep=True, inplace=False, **kwargs): logger.warning("Filtered IamDataFrame is empty!") ret.meta = ret.meta.loc[idx] ret.meta.index = ret.meta.index.remove_unused_levels() + ret._exclude = ret._exclude.loc[idx] + ret._exclude.index = ret._exclude.index.remove_unused_levels() ret._set_attributes() if not inplace: return ret @@ -1896,7 +1940,17 @@ def _apply_filters(self, level=None, **filters): if values is None: continue - if col in self.meta.columns: + if col == "exclude": + if not isinstance(values, bool): + raise ValueError( + f"Filter by `exclude` requires a boolean, found: {values}" + ) + exclude_index = (self.exclude[self.exclude == values]).index + keep_col = _make_index( + self._data, cols=self.index.names, unique=False + ).isin(exclude_index) + + elif col in self.meta.columns: matches = pattern_match( self.meta[col], values, regexp=regexp, has_nan=True ) @@ -2384,7 +2438,7 @@ def to_excel( write_sheet(excel_writer, sheet_name, self._to_file_format(iamc_index)) # write meta table unless `include_meta=False` - if include_meta: + if include_meta and len(self.meta.columns): meta_rename = dict([(i, i.capitalize()) for i in self.index.names]) write_sheet( excel_writer, @@ -2505,6 +2559,18 @@ def load_meta( else: logger.info(msg) + # in pyam < 2.0, an "exclude" columns was part of the `meta` attribute + # this section ensures compatibility with xlsx files created with pyam < 2.0 + if "exclude" in df.columns: + logger.info( + f"Found column 'exclude' in sheet '{sheet_name}', " + "moved to attribute `IamDataFrame.exclude`." + ) + self._exclude = merge_exclude( + df.exclude, self.exclude, ignore_conflict=ignore_conflict + ) + df.drop(columns="exclude", inplace=True) + # merge imported meta indicators self.meta = merge_meta(df, self.meta, ignore_conflict=ignore_conflict) @@ -2704,7 +2770,7 @@ def validate(df, criteria={}, exclude_on_fail=False, **kwargs): message or returns None if all scenarios match the criteria. When called with `exclude_on_fail=True`, scenarios in `df` not satisfying - the criteria will be marked as `exclude=True` (object modified in place). + the criteria will be marked as :attr:`exclude` = *True*. Parameters ---------- @@ -2716,7 +2782,7 @@ def validate(df, criteria={}, exclude_on_fail=False, **kwargs): fdf = df.filter(**kwargs) if len(fdf.data) > 0: vdf = fdf.validate(criteria=criteria, exclude_on_fail=exclude_on_fail) - df.meta["exclude"] |= fdf.meta["exclude"] # update if any excluded + df._exclude |= fdf._exclude # update if any excluded return vdf @@ -2737,15 +2803,14 @@ def require_variable( vdf = fdf.require_variable( variable=variable, unit=unit, year=year, exclude_on_fail=exclude_on_fail ) - df.meta["exclude"] |= fdf.meta["exclude"] # update if any excluded + df._exclude |= fdf._exclude # update if any excluded return vdf def categorize( df, name, value, criteria, color=None, marker=None, linestyle=None, **kwargs ): - """Assign scenarios to a category according to specific criteria - or display the category assignment + """Assign scenarios to a category according to specific criteria. Parameters ---------- @@ -2774,8 +2839,7 @@ def categorize( def check_aggregate( df, variable, components=None, exclude_on_fail=False, multiplier=1, **kwargs ): - """Check whether the timeseries values match the aggregation - of sub-categories + """Check whether the timeseries values match the aggregation of sub-categories Parameters ---------- @@ -2792,7 +2856,7 @@ def check_aggregate( exclude_on_fail=exclude_on_fail, multiplier=multiplier, ) - df.meta["exclude"] |= fdf.meta["exclude"] # update if any excluded + df._exclude |= fdf._exclude # update if any excluded return vdf diff --git a/pyam/iiasa.py b/pyam/iiasa.py index d5eeb6950..651468cbe 100644 --- a/pyam/iiasa.py +++ b/pyam/iiasa.py @@ -335,6 +335,10 @@ def meta(self, default_only=True, run_id=False, **kwargs): extra_meta = pd.DataFrame.from_records(df.metadata) meta = pd.concat([meta, extra_meta], axis=1) + # remove "exclude" column when querying from an ixmp (legacy) IIASA database + if "exclude" in meta.columns: + meta.drop(columns="exclude", inplace=True) + return meta.set_index(META_IDX + ([] if default_only else ["version"])) def properties(self, default_only=True, **kwargs): diff --git a/pyam/logging.py b/pyam/logging.py index fd63173d8..e34e00632 100644 --- a/pyam/logging.py +++ b/pyam/logging.py @@ -1,7 +1,9 @@ from contextlib import contextmanager import logging +import pandas as pd import warnings + logger = logging.getLogger(__name__) @@ -24,9 +26,10 @@ def deprecation_warning(msg, item="This method", stacklevel=3): def raise_data_error(msg, data): """Utils function to format error message from data formatting""" + if isinstance(data, pd.MultiIndex): + data = data.to_frame(index=False) data = data.drop_duplicates() msg = f"{msg}:\n{data.head()}" + ("\n..." if len(data) > 5 else "") - logger.error(msg) raise ValueError(msg) diff --git a/pyam/testing.py b/pyam/testing.py index 972e6b832..9d09fd52e 100644 --- a/pyam/testing.py +++ b/pyam/testing.py @@ -28,6 +28,12 @@ def assert_iamframe_equal(left, right, **kwargs): pdt.assert_frame_equal( left.meta.dropna(axis="columns", how="all"), right.meta.dropna(axis="columns", how="all"), + check_column_type=False, check_dtype=False, check_like=True, ) + + pdt.assert_series_equal( + left.exclude, + right.exclude, + ) diff --git a/pyam/utils.py b/pyam/utils.py index 8d86677f6..c64b3be5e 100644 --- a/pyam/utils.py +++ b/pyam/utils.py @@ -26,7 +26,7 @@ REQUIRED_COLS = ["region", "variable", "unit"] # illegal terms for data/meta column names to prevent attribute conflicts -ILLEGAL_COLS = ["data", "meta", "level", ""] +ILLEGAL_COLS = ["data", "meta", "level", "exclude", ""] # dictionary to translate column count to Excel column names NUMERIC_TO_STR = dict( @@ -488,6 +488,28 @@ def merge_meta(left, right, ignore_conflict=False): return left.dropna(axis=1, how="all") +def merge_exclude(left, right, ignore_conflict=False): + """Merge two `exclude` series; raise if values are in conflict (optional)""" + + left = left.copy() # make a copy to not change the original object + diff = right.index.difference(left.index) + sect = right.index.intersection(left.index) + + # if not ignored, check that overlapping `meta` columns are equal + if not sect.empty: + conflict = left[sect][left[sect] != right[sect]].index + if not conflict.empty: + n = len(conflict) + if ignore_conflict: + logger.warning(f"Ignoring conflict{s(n)} in `exclude` attribute.") + else: + raise_data_error( + f"Conflict when merging `exclude` for the following scenario{s(n)}", + conflict, + ) + return pd.concat([left, right.loc[diff]], sort=False) + + def find_depth(data, s="", level=None): """Return or assert the depth (number of ``|``) of variables diff --git a/tests/data/exclude_meta_sheet.xlsx b/tests/data/exclude_meta_sheet.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..32f851eff644e239a6888fecfae2c28e3405e684 GIT binary patch literal 11795 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